diff --git a/CHANGELOG.md b/CHANGELOG.md index 488dd4807e9..d8210cdd5f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming +### ✅ Added +- Add `ChatChannelListController.prefill(group:completion:)` for priming controller-local channel data [#4071](https://github.com/GetStream/stream-chat-swift/pull/4071) +- Add `ChatClient.queryGroupedChannels(limit:watch:presence:)` to fetch grouped channels with per group unread counts [#4071](https://github.com/GetStream/stream-chat-swift/pull/4071) +- Add optional `groupedUnreadChannels` data to relevant web-socket events and to `CurrentChatUser` [#4071](https://github.com/GetStream/stream-chat-swift/pull/4071) + ### 🔄 Changed +- Make grouped channels decoding tolerate missing `unread_count` and `unread_channels` fields in group buckets, matching the current OpenAPI schema [#4071](https://github.com/GetStream/stream-chat-swift/pull/4071) # [4.99.1](https://github.com/GetStream/stream-chat-swift/releases/tag/4.99.1) _April 01, 2026_ diff --git a/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift index 5fba4bbffe3..bf0845982f7 100644 --- a/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift +++ b/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift @@ -15,6 +15,18 @@ extension Endpoint { ) } + static func groupedChannels( + request: GroupedQueryChannelsRequestBody + ) -> Endpoint { + .init( + path: .groupedChannels, + method: .post, + queryItems: nil, + requiresConnectionId: request.watch || request.presence, + body: request + ) + } + static func createChannel(query: ChannelQuery) -> Endpoint { createOrUpdateChannel(path: .createChannel(query.apiPath), query: query) } diff --git a/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift b/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift index 1644c05eebc..63da32625c4 100644 --- a/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift +++ b/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift @@ -9,7 +9,7 @@ extension EndpointPath { switch self { case .sendMessage, .editMessage, .deleteMessage, .pinMessage, .unpinMessage, .addReaction, .deleteReaction, .draftMessage: return true - case .createChannel, .connect, .sync, .users, .guest, .members, .partialMemberUpdate, .search, .devices, .channels, .updateChannel, + case .createChannel, .connect, .sync, .users, .guest, .members, .partialMemberUpdate, .search, .devices, .channels, .groupedChannels, .updateChannel, .deleteChannel, .channelUpdate, .muteChannel, .showChannel, .truncateChannel, .markChannelRead, .markChannelUnread, .markAllChannelsRead, .markChannelsDelivered, .channelEvent, .stopWatchingChannel, .pinnedMessages, .uploadChannelAttachment, .message, .replies, .reactions, .messageAction, .banMember, .flagUser, .flagMessage, .muteUser, .translateMessage, diff --git a/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift b/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift index 5d960b2ac91..7bed7823687 100644 --- a/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift +++ b/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift @@ -24,6 +24,7 @@ enum EndpointPath: Codable { case markThreadUnread(cid: ChannelId) case channels + case groupedChannels case createChannel(String) case updateChannel(String) case deleteChannel(String) @@ -116,6 +117,7 @@ enum EndpointPath: Codable { case .liveLocations: return "users/live_locations" case .channels: return "channels" + case .groupedChannels: return "channels/grouped" case let .createChannel(queryString): return "channels/\(queryString)/query" case let .updateChannel(queryString): return "channels/\(queryString)/query" case let .deleteChannel(payloadPath): return "channels/\(payloadPath)" diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift index 7a8a9020ff3..f4ad667a164 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift @@ -25,6 +25,60 @@ extension ChannelListPayload: Decodable { } } +final class GroupedQueryChannelsRequestBody: Encodable { + let limit: Int? + let watch: Bool + let presence: Bool + + init(limit: Int?, watch: Bool, presence: Bool) { + self.limit = limit + self.watch = watch + self.presence = presence + } +} + +final class GroupedQueryChannelsPayload: Decodable { + let groups: [String: GroupedQueryChannelsGroupPayload] + let duration: String + + init(groups: [String: GroupedQueryChannelsGroupPayload], duration: String) { + self.groups = groups + self.duration = duration + } + + enum CodingKeys: String, CodingKey { + case groups + case duration + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + groups = try container.decode([String: GroupedQueryChannelsGroupPayload].self, forKey: .groups) + duration = try container.decode(String.self, forKey: .duration) + } +} + +final class GroupedQueryChannelsGroupPayload: Decodable { + let channels: [ChannelPayload] + let unreadChannels: Int + + init(channels: [ChannelPayload], unreadChannels: Int) { + self.channels = channels + self.unreadChannels = unreadChannels + } + + enum CodingKeys: String, CodingKey { + case channels + case unreadChannels = "unread_channels" + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + channels = try container.decodeArrayIgnoringFailures([ChannelPayload].self, forKey: .channels) + unreadChannels = try container.decodeIfPresent(Int.self, forKey: .unreadChannels) ?? 0 + } +} + struct ChannelPayload { let channel: ChannelDetailPayload diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index b1ea84c67cd..7f0bd869646 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -615,7 +615,7 @@ public class ChatClient { eventNotificationCenter.subscribe(handler: handler) } - // MARK: - + // MARK: - App Settings /// Fetches the app settings and updates the ``ChatClient/appSettings``. /// - Parameter completion: The completion block once the app settings has finished fetching. @@ -645,6 +645,36 @@ public class ChatClient { } } + // MARK: - Grouped Channels + + /// Queries grouped channel groups for the app. + public func queryGroupedChannels( + limit: Int? = nil, + watch: Bool = false, + presence: Bool = false, + completion: @escaping @MainActor (Result) -> Void + ) { + channelListUpdater.queryGroupedChannels( + limit: limit, + watch: watch, + presence: presence, + completion: completion + ) + } + + /// Queries grouped channel groups for the app. + public func queryGroupedChannels( + limit: Int? = nil, + watch: Bool = false, + presence: Bool = false + ) async throws -> GroupedChannels { + try await withCheckedThrowingContinuation { continuation in + queryGroupedChannels(limit: limit, watch: watch, presence: presence) { result in + continuation.resume(with: result) + } + } + } + // MARK: - Upload attachments /// Uploads an attachment to the specified CDN. diff --git a/Sources/StreamChat/Config/BaseURL.swift b/Sources/StreamChat/Config/BaseURL.swift index 43308de63b0..75ff3e8375e 100644 --- a/Sources/StreamChat/Config/BaseURL.swift +++ b/Sources/StreamChat/Config/BaseURL.swift @@ -7,7 +7,7 @@ import Foundation /// A struct representing base URL for `ChatClient`. public struct BaseURL: CustomStringConvertible { /// The default base URL for StreamChat service. - public static let `default` = BaseURL(urlString: "https://chat.stream-io-api.com/")! + public static let `default` = BaseURL(urlString: "https://chat-edge-dublin-ce2.stream-io-api.com/")! /// The base url for StreamChat data center located in the US East Cost. public static let usEast = BaseURL(urlString: "https://chat-proxy-us-east.stream-io-api.com/")! diff --git a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift index 50e3d7451c2..d7e4d466af3 100644 --- a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift +++ b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift @@ -35,7 +35,7 @@ extension ChatClient { /// - Note: For an async-await alternative of the `ChatChannelListController`, please check ``ChannelList`` in the async-await supported [state layer](https://getstream.io/chat/docs/sdk/ios/client/state-layer/state-layer-overview/). public class ChatChannelListController: DataController, DelegateCallable, DataStoreProvider { /// The query specifying and filtering the list of channels. - public let query: ChannelListQuery + public private(set) var query: ChannelListQuery /// The `ChatClient` instance this controller belongs to. public let client: ChatClient @@ -69,6 +69,7 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt /// A Boolean value that returns whether pagination is finished public private(set) var hasLoadedAllPreviousChannels: Bool = false + @Atomic private var shouldSkipInitialRemoteUpdate = false /// A type-erased delegate. var multicastDelegate: MulticastDelegate = .init() { @@ -158,6 +159,18 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt startChannelListObserverIfNeeded() channelListLinker.start(with: client.eventNotificationCenter) client.syncRepository.startTrackingChannelListController(self) + + if shouldSkipInitialRemoteUpdate { + shouldSkipInitialRemoteUpdate = false + state = .remoteDataFetched + hasLoadedAllPreviousChannels = channels.isEmpty + markChannelsAsDeliveredIfNeeded(channels: Array(channels)) + callback { + completion?(nil) + } + return + } + updateChannelList(completion) } @@ -194,6 +207,50 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt } } + /// Prefills the controller with an initial channel list snapshot and skips the first remote + /// `queryChannels` request when `synchronize()` is called afterwards. + /// + /// The prefetched channels are persisted in the local storage and linked only to this + /// controller query, so pagination, local observation and offline refresh keep working. + public func prefill( + group: GroupedChannelsGroup, + completion: ((Error?) -> Void)? = nil + ) { + let prefilledChannels = filter.map { runtimeFilter in + group.channels.filter(runtimeFilter) + } ?? group.channels + let prefilledGroup = GroupedChannelsGroup( + groupKey: group.groupKey, + channels: prefilledChannels, + unreadChannels: group.unreadChannels + ) + // This changes filter hash to use static group key + query.groupKey = group.groupKey + + worker.prefill(group: prefilledGroup, for: query) { [weak self] result in + guard let self else { return } + switch result { + case let .success(savedChannels): + self.shouldSkipInitialRemoteUpdate = true + // Prefill can come from a differently sized grouped endpoint page, so we can + // only conclude pagination is exhausted when no channels were provided at all. + self.hasLoadedAllPreviousChannels = savedChannels.isEmpty + // When prefilling with a lot of channels, make the `channels` property to reflect it + // This makes channels.count to reflect the currently loaded channels count + if prefilledChannels.count > self.query.pagination.pageSize { + self.channelListObserver.updateFetchLimit(prefilledChannels.count) + } + self.callback { + completion?(nil) + } + case let .failure(error): + self.callback { + completion?(error) + } + } + } + } + @available(*, deprecated, message: "Please use `markAllRead` available in `CurrentChatUserController`") public func markAllRead(completion: ((Error?) -> Void)? = nil) { worker.markAllRead { error in @@ -206,8 +263,7 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt // MARK: - Internal func refreshLoadedChannels(completion: @escaping (Result, Error>) -> Void) { - let channelCount = channelListObserver.items.count - worker.refreshLoadedChannels(for: query, channelCount: channelCount, completion: completion) + worker.refreshLoadedChannels(for: query, channelCount: channels.count, completion: completion) } // MARK: - Helpers diff --git a/Sources/StreamChat/Controllers/DatabaseObserver/BackgroundDatabaseObserver.swift b/Sources/StreamChat/Controllers/DatabaseObserver/BackgroundDatabaseObserver.swift index 988e406d6ba..d40109f987c 100644 --- a/Sources/StreamChat/Controllers/DatabaseObserver/BackgroundDatabaseObserver.swift +++ b/Sources/StreamChat/Controllers/DatabaseObserver/BackgroundDatabaseObserver.swift @@ -109,6 +109,23 @@ class BackgroundDatabaseObserver { } } + /// Updates the underlying fetch request's `fetchLimit` and re-runs `performFetch` + /// on the FRC's managed object context. Use this to grow (or shrink) the set of + /// items the observer exposes without tearing it down and losing delegate wiring. + func updateFetchLimit(_ newLimit: Int) { + frc.fetchRequest.fetchLimit = newLimit + frc.managedObjectContext.perform { [weak self] in + guard let self else { return } + do { + try self.frc.performFetch() + } catch { + log.error("Failed to re-fetch after updating fetchLimit to \(newLimit): \(error)") + return + } + self.updateItems(nil) + } + } + /// Starts observing the changes in the database. /// - Throws: An error if the fetch fails. func startObserving() throws { diff --git a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift index 6952bbba100..8bf0391bcec 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift @@ -274,7 +274,9 @@ extension NSManagedObjectContext { dto.deletedAt = payload.deletedAt?.bridgeDate dto.updatedAt = payload.updatedAt.bridgeDate dto.defaultSortingAt = (payload.lastMessageAt ?? payload.createdAt).bridgeDate - dto.lastMessageAt = payload.lastMessageAt?.bridgeDate + if let lastMessageAt = payload.lastMessageAt { + dto.lastMessageAt = lastMessageAt.bridgeDate + } dto.memberCount = Int64(clamping: payload.memberCount) if let messageCount = payload.messageCount { @@ -349,7 +351,7 @@ extension NSManagedObjectContext { dto.reads.formUnion(reads) try payload.messages.forEach { _ = try saveMessage(payload: $0, channelDTO: dto, syncOwnReactions: true, cache: cache) } - + var pendingMessages = Set() try payload.pendingMessages?.forEach { let pending = try saveMessage( @@ -441,7 +443,7 @@ extension NSManagedObjectContext { } func delete(query: ChannelListQuery) { - guard let dto = channelListQuery(filterHash: query.filter.filterHash) else { return } + guard let dto = channelListQuery(query) else { return } delete(dto) } @@ -474,7 +476,7 @@ extension ChannelDTO { request.sortDescriptors = sortDescriptors.isEmpty ? [ChannelListSortingKey.defaultSortDescriptor] : sortDescriptors - let matchingQuery = NSPredicate(format: "ANY queries.filterHash == %@", query.filter.filterHash) + let matchingQuery = NSPredicate(format: "ANY queries.filterHash == %@", query.queryHash) let notDeleted = NSPredicate(format: "deletedAt == nil") var subpredicates: [NSPredicate] = [ diff --git a/Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift index 0b977f305a2..406b6952d80 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift @@ -35,21 +35,21 @@ class ChannelListQueryDTO: NSManagedObject { } extension NSManagedObjectContext { - func channelListQuery(filterHash: String) -> ChannelListQueryDTO? { - ChannelListQueryDTO.load(filterHash: filterHash, context: self) + func channelListQuery(_ query: ChannelListQuery) -> ChannelListQueryDTO? { + ChannelListQueryDTO.load(filterHash: query.queryHash, context: self) } func saveQuery(query: ChannelListQuery) -> ChannelListQueryDTO { - if let existingDTO = channelListQuery(filterHash: query.filter.filterHash) { + if let existingDTO = channelListQuery(query) { return existingDTO } let request = ChannelListQueryDTO.fetchRequest( keyPath: #keyPath(ChannelListQueryDTO.filterHash), - equalTo: query.filter.filterHash + equalTo: query.queryHash ) let newDTO = NSEntityDescription.insertNewObject(into: self, for: request) - newDTO.filterHash = query.filter.filterHash + newDTO.filterHash = query.queryHash let jsonData: Data do { diff --git a/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift b/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift index 414988f456f..31bceac8e25 100644 --- a/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift +++ b/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift @@ -7,6 +7,7 @@ import Foundation @objc(CurrentUserDTO) class CurrentUserDTO: NSManagedObject { + @NSManaged var groupedUnreadChannelsData: Data? @NSManaged var unreadChannelsCount: Int64 @NSManaged var unreadMessagesCount: Int64 @NSManaged var unreadThreadsCount: Int64 @@ -144,6 +145,16 @@ extension NSManagedObjectContext: CurrentUserDatabaseSession { } } + func saveCurrentUserGroupedUnreadChannels(_ groupedUnreadChannels: GroupedUnreadChannels) throws { + invalidateCurrentUserCache() + + guard let dto = currentUser else { + throw ClientError.CurrentUserDoesNotExist() + } + + dto.groupedUnreadChannels = groupedUnreadChannels + } + func saveCurrentUserDevices(_ devices: [DevicePayload], clearExisting: Bool) throws -> [DeviceDTO] { invalidateCurrentUserCache() @@ -212,6 +223,18 @@ extension NSManagedObjectContext: CurrentUserDatabaseSession { } } +extension CurrentUserDTO { + var groupedUnreadChannels: GroupedUnreadChannels? { + get { + guard let groupedUnreadChannelsData else { return nil } + return try? JSONDecoder.default.decode(GroupedUnreadChannels.self, from: groupedUnreadChannelsData) + } + set { + groupedUnreadChannelsData = newValue.flatMap { try? JSONEncoder.default.encode($0) } + } + } +} + extension CurrentUserDTO { override class func prefetchedRelationshipKeyPaths() -> [String] { [ @@ -282,6 +305,7 @@ extension CurrentChatUser { messages: Int(dto.unreadMessagesCount), threads: Int(dto.unreadThreadsCount) ), + groupedUnreadChannels: dto.groupedUnreadChannels, mutedChannels: mutedChannels, privacySettings: .init( typingIndicators: .init(enabled: dto.isTypingIndicatorsEnabled), diff --git a/Sources/StreamChat/Database/DatabaseSession.swift b/Sources/StreamChat/Database/DatabaseSession.swift index 111e08468a6..819f2bbf527 100644 --- a/Sources/StreamChat/Database/DatabaseSession.swift +++ b/Sources/StreamChat/Database/DatabaseSession.swift @@ -58,6 +58,10 @@ protocol CurrentUserDatabaseSession { /// If there is no current user, the error will be thrown. func saveCurrentUserUnreadCount(count: UnreadCountPayload) throws + /// Updates the `CurrentUserDTO` with grouped unread channel counts. + /// If there is no current user, the error will be thrown. + func saveCurrentUserGroupedUnreadChannels(_ groupedUnreadChannels: GroupedUnreadChannels) throws + /// Updates the `CurrentUserDTO.devices` with the provided `DevicesPayload` /// If there's no current user set, an error will be thrown. @discardableResult @@ -349,9 +353,10 @@ protocol ChannelDatabaseSession { cache: PreWarmedCache? ) throws -> ChannelDTO - /// Loads channel list query with the given filter hash from the database. - /// - Parameter filterHash: The filter hash. - func channelListQuery(filterHash: String) -> ChannelListQueryDTO? + /// Loads the `ChannelListQueryDTO` corresponding to the given `ChannelListQuery`. + /// Lookup uses `query.queryHash` — `groupKey` when set, otherwise `filter.filterHash`. + /// - Parameter query: The channel list query. + func channelListQuery(_ query: ChannelListQuery) -> ChannelListQueryDTO? /// Loads all channel list queries from the database. /// - Returns: The array of channel list queries. @@ -749,6 +754,10 @@ extension DatabaseSession { try saveCurrentUserUnreadCount(count: unreadCount) } + if let groupedUnreadChannels = payload.groupedUnreadChannels { + try saveCurrentUserGroupedUnreadChannels(groupedUnreadChannels) + } + if let threadDetailsPayload = payload.threadDetails?.value { try saveThread(detailsPayload: threadDetailsPayload) } diff --git a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents index cb91d28b21f..4cf3bdaa0e9 100644 --- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents +++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents @@ -164,6 +164,7 @@ + @@ -591,4 +592,4 @@ - \ No newline at end of file + diff --git a/Sources/StreamChat/Models/CurrentUser.swift b/Sources/StreamChat/Models/CurrentUser.swift index f9ca36742f1..4a72519290e 100644 --- a/Sources/StreamChat/Models/CurrentUser.swift +++ b/Sources/StreamChat/Models/CurrentUser.swift @@ -55,6 +55,9 @@ public class CurrentChatUser: ChatUser { /// The unread counts for the current user. public let unreadCount: UnreadCount + /// Grouped unread channel counts keyed by the backend-provided group identifier. + public let groupedUnreadChannels: GroupedUnreadChannels? + /// A Boolean value indicating if the user has opted to hide their online status. public let isInvisible: Bool @@ -87,6 +90,7 @@ public class CurrentChatUser: ChatUser { flaggedUsers: Set, flaggedMessageIDs: Set, unreadCount: UnreadCount, + groupedUnreadChannels: GroupedUnreadChannels? = nil, mutedChannels: Set, privacySettings: UserPrivacySettings, avgResponseTime: Int?, @@ -99,6 +103,7 @@ public class CurrentChatUser: ChatUser { self.flaggedUsers = flaggedUsers self.flaggedMessageIDs = flaggedMessageIDs self.unreadCount = unreadCount + self.groupedUnreadChannels = groupedUnreadChannels self.isInvisible = isInvisible self.privacySettings = privacySettings self.mutedChannels = mutedChannels diff --git a/Sources/StreamChat/Models/GroupedChannels.swift b/Sources/StreamChat/Models/GroupedChannels.swift new file mode 100644 index 00000000000..506e058869c --- /dev/null +++ b/Sources/StreamChat/Models/GroupedChannels.swift @@ -0,0 +1,45 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// A grouped channels response returned by `ChatClient.queryGroupedChannels`. +public struct GroupedChannels: Equatable { + /// The grouped channel groups returned by the backend, keyed by group name. + public let groups: [String: GroupedChannelsGroup] + + init( + groups: [String: GroupedChannelsGroup] + ) { + self.groups = groups + } +} + +/// A grouped channels group returned by `ChatClient.queryGroupedChannels`. +public struct GroupedChannelsGroup: Equatable { + /// The group key as returned by the backend (e.g. `"all"`, `"new"`, `"current"`). + public let groupKey: String + + /// The channels that belong to this group. + public let channels: [ChatChannel] + + /// The total unread channel count in the group. + public let unreadChannels: Int + + init( + groupKey: String, + channels: [ChatChannel], + unreadChannels: Int + ) { + self.groupKey = groupKey + self.channels = channels + let derivedUnreadChannels = channels.reduce(into: 0) { partialResult, channel in + if channel.unreadCount.messages > 0 { + partialResult += 1 + } + } + + self.unreadChannels = max(unreadChannels, derivedUnreadChannels) + } +} diff --git a/Sources/StreamChat/Models/UnreadCount.swift b/Sources/StreamChat/Models/UnreadCount.swift index cc2ce359c29..7282bf04634 100644 --- a/Sources/StreamChat/Models/UnreadCount.swift +++ b/Sources/StreamChat/Models/UnreadCount.swift @@ -4,6 +4,9 @@ import Foundation +/// Grouped unread channel counts keyed by the backend-provided group identifier. +public typealias GroupedUnreadChannels = [String: Int] + /// A struct containing information about unread counts of channels and messages. public struct UnreadCount: Decodable, Equatable { /// The default value representing no unread channels, messages and threads. diff --git a/Sources/StreamChat/Query/ChannelListQuery.swift b/Sources/StreamChat/Query/ChannelListQuery.swift index eda6a285deb..52683a564b7 100644 --- a/Sources/StreamChat/Query/ChannelListQuery.swift +++ b/Sources/StreamChat/Query/ChannelListQuery.swift @@ -71,6 +71,15 @@ public struct ChannelListQuery: Encodable, LocalConvertibleSortingQuery { try options.encode(to: encoder) try pagination.encode(to: encoder) } + + var groupKey: String? + + /// The stable identity used for locating / linking the corresponding `ChannelListQueryDTO`. + /// Uses `groupKey` when set (stable across date-bearing filters from the grouped endpoint); + /// otherwise falls back to `filter.filterHash`. + var queryHash: String { + groupKey ?? filter.filterHash + } } extension ChannelListQuery: CustomDebugStringConvertible { @@ -245,6 +254,38 @@ public extension FilterKey where Scope == ChannelListFilterScope { /// Supported operators: `equal`, `greaterThan`, `lessThan`, `greaterOrEqual`, `lessOrEqual` static var memberCount: FilterKey { .init(rawValue: "member_count", keyPathString: #keyPath(ChannelDTO.memberCount)) } + /// A filter key for matching the `messageCount` value. + /// Supported operators: `equal`, `greaterThan`, `lessThan`, `greaterOrEqual`, `lessOrEqual` + /// + /// Only returns value if `count_messages` is configured for your app. + /// + /// For local filtering, the stored `ChannelDTO.messageCount` is used when the + /// backend delivered an accurate total. When the backend omits it, the filter + /// falls back to the count of the cached `messages` relationship so predicates + /// still behave sensibly. + static var messageCount: FilterKey { + .init( + rawValue: "message_count", + keyPathString: #keyPath(ChannelDTO.messageCount), + predicateMapper: { op, value in + let operatorString: String + switch op { + case .equal: operatorString = "==" + case .greater: operatorString = ">" + case .greaterOrEqual: operatorString = ">=" + case .less: operatorString = "<" + case .lessOrEqual: operatorString = "<=" + default: return nil + } + let storedKey = #keyPath(ChannelDTO.messageCount) + let format = "(\(storedKey) != nil AND \(storedKey) \(operatorString) %@)" + + " OR (\(storedKey) == nil AND messages.@count \(operatorString) %@)" + let predicateValue = NSNumber(value: value) + return NSPredicate(format: format, predicateValue, predicateValue) + } + ) + } + /// A filter key for matching the `team` value. /// Supported operators: `equal` static var team: FilterKey { .init(rawValue: "team", keyPathString: #keyPath(ChannelDTO.team)) } diff --git a/Sources/StreamChat/Repositories/SyncOperations.swift b/Sources/StreamChat/Repositories/SyncOperations.swift index 9a82aa8b849..86e85ea4fe9 100644 --- a/Sources/StreamChat/Repositories/SyncOperations.swift +++ b/Sources/StreamChat/Repositories/SyncOperations.swift @@ -107,6 +107,60 @@ final class RefreshChannelListOperation: AsyncOperation, @unchecked Sendable { } } +final class SyncGroupedChannelsOperation: AsyncOperation, @unchecked Sendable { + init( + channelListUpdater: ChannelListUpdater, + controllers: [ChatChannelListController], + context: SyncContext + ) { + super.init(maxRetries: syncOperationsMaximumRetries) { [weak channelListUpdater] _, done in + guard let channelListUpdater else { + done(.continue) + return + } + channelListUpdater.queryGroupedChannels { result in + switch result { + case let .success(groupedChannels): + let returnedChannelIds = groupedChannels.groups.values + .flatMap(\.channels) + .map(\.cid) + context.synchedChannelIds.formUnion(returnedChannelIds) + log.debug( + "Synced \(returnedChannelIds.count) grouped channels across \(groupedChannels.groups.count) group(s)", + subsystems: .offlineSupport + ) + + // Forward each returned group to the matching prefilled controller so the + // controller's local query-DTO links and observer state get refreshed. + let dispatchGroup = DispatchGroup() + for controller in controllers { + guard + let key = controller.query.groupKey, + let group = groupedChannels.groups[key] + else { continue } + dispatchGroup.enter() + controller.prefill(group: group) { error in + if let error { + log.error( + "Failed to prefill controller for group \(key): \(error)", + subsystems: .offlineSupport + ) + } + dispatchGroup.leave() + } + } + dispatchGroup.notify(queue: .global(qos: .utility)) { + done(.continue) + } + case let .failure(error): + log.error("Failed to refresh grouped channels during sync: \(error)", subsystems: .offlineSupport) + done(.retry) + } + } + } + } +} + final class SyncEventsOperation: AsyncOperation, @unchecked Sendable { init(syncRepository: SyncRepository, context: SyncContext, recovery: Bool) { super.init(maxRetries: syncOperationsMaximumRetries) { [weak syncRepository] _, done in diff --git a/Sources/StreamChat/Repositories/SyncRepository.swift b/Sources/StreamChat/Repositories/SyncRepository.swift index 137a338b5ed..c3d7ba66160 100644 --- a/Sources/StreamChat/Repositories/SyncRepository.swift +++ b/Sources/StreamChat/Repositories/SyncRepository.swift @@ -160,7 +160,8 @@ class SyncRepository { /// /// Background mode (other regular API requests are allowed to run at the same time) /// 1. Collect all the **active** channel ids (from instances of `Chat`, `ChannelList`, `ChatChannelController`, `ChatChannelListController`) - /// 2. Refresh channel lists (channels for current pages in `ChannelList`, `ChatChannelListController`) + /// 2. Refresh channel lists (channels for current pages in `ChannelList`, non-prefilled `ChatChannelListController`) + /// 2.5 Refresh the shared grouped channels response when any prefilled `ChatChannelListController` is active /// 3. Apply updates from the /sync endpoint for channels not in active channel lists (max 2000 events is supported) /// * channel controllers targeting other channels /// * no channel lists active, but channel controllers are @@ -192,8 +193,20 @@ class SyncRepository { // 2. Refresh channel lists operations.append(contentsOf: activeChannelLists.allObjects.map { RefreshChannelListOperation(channelList: $0, context: context) }) - operations.append(contentsOf: activeChannelListControllers.allObjects.map { RefreshChannelListOperation(controller: $0, context: context) }) - + let allControllers = activeChannelListControllers.allObjects + let prefilledControllers = allControllers.filter { $0.query.groupKey != nil } + let standardControllers = allControllers.filter { $0.query.groupKey == nil } + operations.append(contentsOf: standardControllers.map { RefreshChannelListOperation(controller: $0, context: context) }) + + // 2.5 Refresh grouped channels (for controllers populated via `prefill(...)`) + if !prefilledControllers.isEmpty { + operations.append(SyncGroupedChannelsOperation( + channelListUpdater: channelListUpdater, + controllers: prefilledControllers, + context: context + )) + } + // 3. /sync (for channels what not part of active channel lists) operations.append(SyncEventsOperation(syncRepository: self, context: context, recovery: false)) diff --git a/Sources/StreamChat/WebSocketClient/Events/ChannelEvents.swift b/Sources/StreamChat/WebSocketClient/Events/ChannelEvents.swift index 5dd48f7f93c..40603c1cb92 100644 --- a/Sources/StreamChat/WebSocketClient/Events/ChannelEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/ChannelEvents.swift @@ -107,7 +107,7 @@ class ChannelDeletedEventDTO: EventDTO { } /// Triggered when a channel is truncated. -public final class ChannelTruncatedEvent: ChannelSpecificEvent { +public final class ChannelTruncatedEvent: ChannelSpecificEvent, HasGroupedUnreadChannels { /// The identifier of deleted channel. public var cid: ChannelId { channel.cid } @@ -123,11 +123,21 @@ public final class ChannelTruncatedEvent: ChannelSpecificEvent { /// The event timestamp. public let createdAt: Date - init(channel: ChatChannel, user: ChatUser?, message: ChatMessage?, createdAt: Date) { + /// Grouped unread channel counts keyed by the backend-provided group identifier. + public let groupedUnreadChannels: GroupedUnreadChannels? + + init( + channel: ChatChannel, + user: ChatUser?, + message: ChatMessage?, + createdAt: Date, + groupedUnreadChannels: GroupedUnreadChannels? = nil + ) { self.channel = channel self.user = user self.message = message self.createdAt = createdAt + self.groupedUnreadChannels = groupedUnreadChannels } } @@ -156,7 +166,8 @@ class ChannelTruncatedEventDTO: EventDTO { channel: channelDTO.asModel(), user: userDTO?.asModel(), message: messageDTO?.asModel(), - createdAt: createdAt + createdAt: createdAt, + groupedUnreadChannels: session.currentUser?.groupedUnreadChannels ) } } diff --git a/Sources/StreamChat/WebSocketClient/Events/Event.swift b/Sources/StreamChat/WebSocketClient/Events/Event.swift index aa74b0c1579..731b84441e7 100644 --- a/Sources/StreamChat/WebSocketClient/Events/Event.swift +++ b/Sources/StreamChat/WebSocketClient/Events/Event.swift @@ -42,6 +42,12 @@ public protocol HasUnreadCount: Event { var unreadCount: UnreadCount? { get } } +/// A protocol for events that carry grouped unread channel counts. +public protocol HasGroupedUnreadChannels: Event { + /// Grouped unread channel counts keyed by the backend-provided group identifier. + var groupedUnreadChannels: GroupedUnreadChannels? { get } +} + /// A protocol for any `MemberEvent` where it has a `member`, and `channel` payload. public protocol MemberEvent: Event { var memberUserId: UserId { get } diff --git a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift index 792d435427c..258fe4b5363 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift @@ -35,6 +35,7 @@ class EventPayload: Decodable { case lastDeliveredAt = "last_delivered_at" case lastDeliveredMessageId = "last_delivered_message_id" case unreadMessagesCount = "unread_messages" + case groupedUnreadChannels = "grouped_unread_channels" case shadow case thread case vote = "poll_vote" @@ -74,6 +75,7 @@ class EventPayload: Decodable { let lastDeliveredAt: Date? let lastDeliveredMessageId: MessageId? let unreadMessagesCount: Int? + let groupedUnreadChannels: GroupedUnreadChannels? var poll: PollPayload? var vote: PollVotePayload? @@ -102,6 +104,7 @@ class EventPayload: Decodable { reaction: MessageReactionPayload? = nil, watcherCount: Int? = nil, unreadCount: UnreadCountPayload? = nil, + groupedUnreadChannels: GroupedUnreadChannels? = nil, createdAt: Date? = nil, isChannelHistoryCleared: Bool? = nil, banReason: String? = nil, @@ -151,6 +154,7 @@ class EventPayload: Decodable { self.lastReadAt = lastReadAt self.lastReadMessageId = lastReadMessageId self.unreadMessagesCount = unreadMessagesCount + self.groupedUnreadChannels = groupedUnreadChannels self.threadPartial = threadPartial self.threadDetails = threadDetails self.poll = poll @@ -194,6 +198,7 @@ class EventPayload: Decodable { lastReadAt = try container.decodeIfPresent(Date.self, forKey: .lastReadAt) lastReadMessageId = try container.decodeIfPresent(MessageId.self, forKey: .lastReadMessageId) unreadMessagesCount = try container.decodeIfPresent(Int.self, forKey: .unreadMessagesCount) + groupedUnreadChannels = try container.decodeIfPresent(GroupedUnreadChannels.self, forKey: .groupedUnreadChannels) threadDetails = container.decodeAsResultIfPresent(ThreadDetailsPayload.self, forKey: .thread) threadPartial = container.decodeAsResultIfPresent(ThreadPartialPayload.self, forKey: .thread) vote = try container.decodeIfPresent(PollVotePayload.self, forKey: .vote) @@ -239,6 +244,7 @@ private extension PartialKeyPath where Root == EventPayload { case \EventPayload.reaction: return "reaction" case \EventPayload.watcherCount: return "watcherCount" case \EventPayload.unreadCount: return "unreadCount" + case \EventPayload.groupedUnreadChannels: return "groupedUnreadChannels" case \EventPayload.createdAt: return "createdAt" case \EventPayload.isChannelHistoryCleared: return "isChannelHistoryCleared" case \EventPayload.banReason: return "banReason" diff --git a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift index 07d21d5474a..324bb1f1672 100644 --- a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift @@ -5,7 +5,7 @@ import Foundation /// Triggered when a new message is sent to channel. -public final class MessageNewEvent: ChannelSpecificEvent, HasUnreadCount { +public final class MessageNewEvent: ChannelSpecificEvent, HasUnreadCount, HasGroupedUnreadChannels { /// The user who sent a message. public let user: ChatUser @@ -27,13 +27,17 @@ public final class MessageNewEvent: ChannelSpecificEvent, HasUnreadCount { /// The unread counts. public let unreadCount: UnreadCount? + /// Grouped unread channel counts keyed by the backend-provided group identifier. + public let groupedUnreadChannels: GroupedUnreadChannels? + init( user: ChatUser, message: ChatMessage, channel: ChatChannel, createdAt: Date, watcherCount: Int?, - unreadCount: UnreadCount? + unreadCount: UnreadCount?, + groupedUnreadChannels: GroupedUnreadChannels? = nil ) { self.user = user self.message = message @@ -41,6 +45,7 @@ public final class MessageNewEvent: ChannelSpecificEvent, HasUnreadCount { self.createdAt = createdAt self.watcherCount = watcherCount self.unreadCount = unreadCount + self.groupedUnreadChannels = groupedUnreadChannels } } @@ -77,7 +82,8 @@ class MessageNewEventDTO: EventDTO { channel: channelDTO.asModel(), createdAt: createdAt, watcherCount: watcherCount, - unreadCount: UnreadCount(currentUserDTO: currentUser) + unreadCount: UnreadCount(currentUserDTO: currentUser), + groupedUnreadChannels: currentUser.groupedUnreadChannels ) } } diff --git a/Sources/StreamChat/WebSocketClient/Events/NotificationEvents.swift b/Sources/StreamChat/WebSocketClient/Events/NotificationEvents.swift index 15b31f9ba12..b2101b7d1f5 100644 --- a/Sources/StreamChat/WebSocketClient/Events/NotificationEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/NotificationEvents.swift @@ -5,7 +5,7 @@ import Foundation /// Triggered when a new message is sent to a channel the current user is member of. -public final class NotificationMessageNewEvent: ChannelSpecificEvent, HasUnreadCount { +public final class NotificationMessageNewEvent: ChannelSpecificEvent, HasUnreadCount, HasGroupedUnreadChannels { /// The identifier of a channel a message is sent to. public var cid: ChannelId { channel.cid } @@ -21,11 +21,21 @@ public final class NotificationMessageNewEvent: ChannelSpecificEvent, HasUnreadC /// The unread counts of the current user. public let unreadCount: UnreadCount? - init(channel: ChatChannel, message: ChatMessage, createdAt: Date, unreadCount: UnreadCount?) { + /// Grouped unread channel counts keyed by the backend-provided group identifier. + public let groupedUnreadChannels: GroupedUnreadChannels? + + init( + channel: ChatChannel, + message: ChatMessage, + createdAt: Date, + unreadCount: UnreadCount?, + groupedUnreadChannels: GroupedUnreadChannels? = nil + ) { self.channel = channel self.message = message self.createdAt = createdAt self.unreadCount = unreadCount + self.groupedUnreadChannels = groupedUnreadChannels } } @@ -55,7 +65,8 @@ class NotificationMessageNewEventDTO: EventDTO { channel: channelDTO.asModel(), message: messageDTO.asModel(), createdAt: createdAt, - unreadCount: UnreadCount(currentUserDTO: currentUser) + unreadCount: UnreadCount(currentUserDTO: currentUser), + groupedUnreadChannels: currentUser.groupedUnreadChannels ) } } @@ -104,7 +115,7 @@ class NotificationMarkAllReadEventDTO: EventDTO { } /// Triggered when a channel the current user is member of is marked as read. -public final class NotificationMarkReadEvent: ChannelSpecificEvent, HasUnreadCount { +public final class NotificationMarkReadEvent: ChannelSpecificEvent, HasUnreadCount, HasGroupedUnreadChannels { /// The current user. public let user: ChatUser @@ -114,23 +125,34 @@ public final class NotificationMarkReadEvent: ChannelSpecificEvent, HasUnreadCou /// The unread counts of the current user. public let unreadCount: UnreadCount? + /// Grouped unread channel counts keyed by the backend-provided group identifier. + public let groupedUnreadChannels: GroupedUnreadChannels? + /// The id of the last read message id public let lastReadMessageId: MessageId? /// The event timestamp. public let createdAt: Date - init(user: ChatUser, cid: ChannelId, unreadCount: UnreadCount?, lastReadMessageId: MessageId?, createdAt: Date) { + init( + user: ChatUser, + cid: ChannelId, + unreadCount: UnreadCount?, + groupedUnreadChannels: GroupedUnreadChannels? = nil, + lastReadMessageId: MessageId?, + createdAt: Date + ) { self.user = user self.cid = cid self.unreadCount = unreadCount + self.groupedUnreadChannels = groupedUnreadChannels self.lastReadMessageId = lastReadMessageId self.createdAt = createdAt } } /// Triggered when a channel the current user is member of is marked as unread. -public final class NotificationMarkUnreadEvent: ChannelSpecificEvent { +public final class NotificationMarkUnreadEvent: ChannelSpecificEvent, HasGroupedUnreadChannels { /// The current user. public let user: ChatUser @@ -152,10 +174,23 @@ public final class NotificationMarkUnreadEvent: ChannelSpecificEvent { /// The unread counts of the current user. public let unreadCount: UnreadCount + /// Grouped unread channel counts keyed by the backend-provided group identifier. + public let groupedUnreadChannels: GroupedUnreadChannels? + /// The number of unread messages for the channel public let unreadMessagesCount: Int - init(user: ChatUser, cid: ChannelId, createdAt: Date, firstUnreadMessageId: MessageId, lastReadMessageId: MessageId?, lastReadAt: Date, unreadCount: UnreadCount, unreadMessagesCount: Int) { + init( + user: ChatUser, + cid: ChannelId, + createdAt: Date, + firstUnreadMessageId: MessageId, + lastReadMessageId: MessageId?, + lastReadAt: Date, + unreadCount: UnreadCount, + groupedUnreadChannels: GroupedUnreadChannels? = nil, + unreadMessagesCount: Int + ) { self.user = user self.cid = cid self.createdAt = createdAt @@ -163,6 +198,7 @@ public final class NotificationMarkUnreadEvent: ChannelSpecificEvent { self.lastReadMessageId = lastReadMessageId self.lastReadAt = lastReadAt self.unreadCount = unreadCount + self.groupedUnreadChannels = groupedUnreadChannels self.unreadMessagesCount = unreadMessagesCount } } @@ -192,6 +228,7 @@ class NotificationMarkReadEventDTO: EventDTO { user: userDTO.asModel(), cid: cid, unreadCount: UnreadCount(currentUserDTO: currentUser), + groupedUnreadChannels: currentUser.groupedUnreadChannels, lastReadMessageId: lastReadMessageId, createdAt: createdAt ) @@ -233,6 +270,7 @@ class NotificationMarkUnreadEventDTO: EventDTO { lastReadMessageId: lastReadMessageId, lastReadAt: lastReadAt, unreadCount: UnreadCount(currentUserDTO: currentUser), + groupedUnreadChannels: currentUser.groupedUnreadChannels, unreadMessagesCount: unreadMessagesCount ) } @@ -586,7 +624,7 @@ class NotificationInviteRejectedEventDTO: EventDTO { } /// Triggered when a channel is deleted, this event is delivered to all channel members -public final class NotificationChannelDeletedEvent: ChannelSpecificEvent { +public final class NotificationChannelDeletedEvent: ChannelSpecificEvent, HasGroupedUnreadChannels { /// The cid of the deleted channel public let cid: ChannelId @@ -596,10 +634,19 @@ public final class NotificationChannelDeletedEvent: ChannelSpecificEvent { /// The event timestamp. public let createdAt: Date - init(cid: ChannelId, channel: ChatChannel, createdAt: Date) { + /// Grouped unread channel counts keyed by the backend-provided group identifier. + public let groupedUnreadChannels: GroupedUnreadChannels? + + init( + cid: ChannelId, + channel: ChatChannel, + createdAt: Date, + groupedUnreadChannels: GroupedUnreadChannels? = nil + ) { self.cid = cid self.channel = channel self.createdAt = createdAt + self.groupedUnreadChannels = groupedUnreadChannels } } @@ -621,7 +668,8 @@ class NotificationChannelDeletedEventDTO: EventDTO { return try? NotificationChannelDeletedEvent( cid: cid, channel: channelDTO.asModel(), - createdAt: createdAt + createdAt: createdAt, + groupedUnreadChannels: session.currentUser?.groupedUnreadChannels ) } } diff --git a/Sources/StreamChat/Workers/ChannelListLinker.swift b/Sources/StreamChat/Workers/ChannelListLinker.swift index b8cf1ce2468..be553f08196 100644 --- a/Sources/StreamChat/Workers/ChannelListLinker.swift +++ b/Sources/StreamChat/Workers/ChannelListLinker.swift @@ -7,10 +7,9 @@ import Foundation /// When we receive events, we need to check if a channel should be added or removed from /// the current query depending on the following events: /// - Channel created: We analyse if the channel should be added to the current query. -/// - New message sent: This means the channel will reorder and appear on first position, -/// so we also analyse if it should be added to the current query. -/// - Channel is updated: We only check if we should remove it from the current query. -/// We don't try to add it to the current query to not mess with pagination. +/// - New message sent: This means the channel can reorder and also move between query-backed +/// lists, so we analyse if it should be removed from or added to the current query. +/// - Channel is updated: We analyse if it should be removed from or added to the current query. final class ChannelListLinker { private let clientConfig: ChatClientConfig private let databaseContainer: DatabaseContainer @@ -46,12 +45,20 @@ final class ChannelListLinker { EventObserver( notificationCenter: nc, transform: { $0 as? MessageNewEvent }, - callback: { [weak self] event in self?.linkChannelIfNeeded(event.channel) } + callback: { [weak self] event in + self?.unlinkChannelIfNeeded(event.channel) { + self?.linkChannelIfNeeded(event.channel) + } + } ), EventObserver( notificationCenter: nc, transform: { $0 as? NotificationMessageNewEvent }, - callback: { [weak self] event in self?.linkChannelIfNeeded(event.channel) } + callback: { [weak self] event in + self?.unlinkChannelIfNeeded(event.channel) { + self?.linkChannelIfNeeded(event.channel) + } + } ), EventObserver( notificationCenter: nc, diff --git a/Sources/StreamChat/Workers/ChannelListUpdater.swift b/Sources/StreamChat/Workers/ChannelListUpdater.swift index 93035c01674..9ddef025b40 100644 --- a/Sources/StreamChat/Workers/ChannelListUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelListUpdater.swift @@ -23,8 +23,7 @@ class ChannelListUpdater: Worker { var initialActions: ((DatabaseSession) -> Void)? if isInitialFetch { initialActions = { session in - let filterHash = channelListQuery.filter.filterHash - guard let queryDTO = session.channelListQuery(filterHash: filterHash) else { return } + guard let queryDTO = session.channelListQuery(channelListQuery) else { return } queryDTO.channels.removeAll() } } @@ -41,6 +40,33 @@ class ChannelListUpdater: Worker { } } + func prefill( + group: GroupedChannelsGroup, + for query: ChannelListQuery, + completion: ((Result<[ChatChannel], Error>) -> Void)? = nil + ) { + var savedChannels: [ChatChannel] = [] + database.write { session in + let queryDTO = session.saveQuery(query: query) + queryDTO.channels.removeAll() + + savedChannels = group.channels.compactMapLoggingError { channel in + guard let channelDTO = session.channel(cid: channel.cid) else { + log.warning("Prefill skipped channel \(channel.cid): not found in the database.") + return nil + } + queryDTO.channels.insert(channelDTO) + return try channelDTO.asModel() + } + } completion: { error in + if let error { + completion?(.failure(error)) + } else { + completion?(.success(savedChannels)) + } + } + } + func refreshLoadedChannels(for query: ChannelListQuery, channelCount: Int, completion: @escaping (Result, Error>) -> Void) { guard channelCount > 0 else { completion(.success(Set())) @@ -166,11 +192,58 @@ class ChannelListUpdater: Worker { completion?(error) } } + + /// Queries grouped channel groups for the app. + func queryGroupedChannels( + limit: Int? = nil, + watch: Bool = false, + presence: Bool = false, + completion: @escaping @MainActor (Result) -> Void + ) { + let request = GroupedQueryChannelsRequestBody( + limit: limit, + watch: watch, + presence: presence + ) + let endpoint: Endpoint = .groupedChannels(request: request) + + apiClient.request(endpoint: endpoint) { [database] result in + switch result { + case let .success(payload): + database.write(converting: { session in + let groupedUnreadChannels = payload.groups.mapValues(\.unreadChannels) + try session.saveCurrentUserGroupedUnreadChannels(groupedUnreadChannels) + + var groups: [String: GroupedChannelsGroup] = [:] + for (name, groupPayload) in payload.groups { + let channels = try groupPayload.channels.map { channelPayload in + let dto = try session.saveChannel(payload: channelPayload) + return try dto.asModel() + } + groups[name] = GroupedChannelsGroup( + groupKey: name, + channels: channels, + unreadChannels: groupPayload.unreadChannels + ) + } + return GroupedChannels(groups: groups) + }, completion: { result in + DispatchQueue.main.async { + completion(result) + } + }) + case let .failure(error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + } } extension DatabaseSession { func getChannelWithQuery(cid: ChannelId, query: ChannelListQuery) -> (ChannelDTO, ChannelListQueryDTO)? { - guard let queryDTO = channelListQuery(filterHash: query.filter.filterHash) else { + guard let queryDTO = channelListQuery(query) else { log.debug("Channel list query has not yet created \(query)") return nil } @@ -214,7 +287,19 @@ extension ChannelListUpdater { } } } - + + func queryGroupedChannels( + limit: Int? = nil, + watch: Bool = false, + presence: Bool = false + ) async throws -> GroupedChannels { + try await withCheckedThrowingContinuation { continuation in + queryGroupedChannels(limit: limit, watch: watch, presence: presence) { result in + continuation.resume(with: result) + } + } + } + // MARK: - func loadChannels(query: ChannelListQuery, pagination: Pagination) async throws -> [ChatChannel] { diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift index 91de1612b88..5a615c6263f 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift @@ -36,6 +36,16 @@ class ChatChannelListController_Mock: ChatChannelListController, Spy { record() refreshLoadedChannelsResult.map(completion) } + + @Atomic var prefill_groups: [GroupedChannelsGroup] = [] + override func prefill( + group: GroupedChannelsGroup, + completion: ((Error?) -> Void)? = nil + ) { + record() + _prefill_groups.mutate { $0.append(group) } + completion?(nil) + } } extension ChatChannelListController_Mock { diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift index 9aa56b4790f..687eb6f0f6e 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift @@ -46,7 +46,7 @@ class DatabaseSession_Mock: DatabaseSession { func saveCurrentDevice(_ deviceId: String) throws { try throwErrorIfNeeded() - return try saveCurrentDevice(deviceId) + return try underlyingSession.saveCurrentDevice(deviceId) } func saveCurrentUserDevices(_ devices: [DevicePayload], clearExisting: Bool) throws -> [DeviceDTO] { @@ -111,12 +111,17 @@ class DatabaseSession_Mock: DatabaseSession { func saveCurrentUser(payload: CurrentUserPayload) throws -> CurrentUserDTO { try throwErrorIfNeeded() - return try saveCurrentUser(payload: payload) + return try underlyingSession.saveCurrentUser(payload: payload) } func saveCurrentUserUnreadCount(count: UnreadCountPayload) throws { try throwErrorIfNeeded() - try saveCurrentUserUnreadCount(count: count) + try underlyingSession.saveCurrentUserUnreadCount(count: count) + } + + func saveCurrentUserGroupedUnreadChannels(_ groupedUnreadChannels: GroupedUnreadChannels) throws { + try throwErrorIfNeeded() + try underlyingSession.saveCurrentUserGroupedUnreadChannels(groupedUnreadChannels) } func deleteDevice(id: DeviceId) { @@ -383,8 +388,8 @@ class DatabaseSession_Mock: DatabaseSession { underlyingSession.saveQuery(query: query) } - func channelListQuery(filterHash: String) -> ChannelListQueryDTO? { - underlyingSession.channelListQuery(filterHash: filterHash) + func channelListQuery(_ query: ChannelListQuery) -> ChannelListQueryDTO? { + underlyingSession.channelListQuery(query) } func loadAllChannelListQueries() -> [ChannelListQueryDTO] { diff --git a/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift b/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift index 3ea7847969a..8ed12cbb37b 100644 --- a/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift +++ b/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift @@ -13,10 +13,17 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy { @Atomic var update_completion: ((Result<[ChatChannel], Error>) -> Void)? @Atomic var update_completion_result: Result<[ChatChannel], Error>? + @Atomic var prefill_queries: [ChannelListQuery] = [] + @Atomic var prefill_channels: [[ChatChannel]] = [] + @Atomic var fetch_queries: [ChannelListQuery] = [] @Atomic var fetch_completion: ((Result) -> Void)? @Atomic var refreshLoadedChannelsResult: Result, Error>? + @Atomic var refreshLoadedChannels_channelCounts: [Int] = [] + + @Atomic var queryGroupedChannels_callCount = 0 + @Atomic var queryGroupedChannels_result: Result? @Atomic var markAllRead_completion: ((Error?) -> Void)? @@ -35,9 +42,15 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy { update_completion = nil update_completion_result = nil + prefill_queries.removeAll() + prefill_channels.removeAll() + fetch_queries.removeAll() fetch_completion = nil + refreshLoadedChannels_channelCounts.removeAll() + queryGroupedChannels_callCount = 0 + queryGroupedChannels_result = nil markAllRead_completion = nil startWatchingChannels_cids.removeAll() @@ -54,6 +67,16 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy { update_completion_result?.invoke(with: completion) } + override func prefill( + group: GroupedChannelsGroup, + for query: ChannelListQuery, + completion: ((Result<[ChatChannel], Error>) -> Void)? = nil + ) { + _prefill_queries.mutate { $0.append(query) } + _prefill_channels.mutate { $0.append(group.channels) } + super.prefill(group: group, for: query, completion: completion) + } + override func markAllRead(completion: ((Error?) -> Void)? = nil) { markAllRead_completion = completion } @@ -72,9 +95,26 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy { completion: @escaping (Result, any Error>) -> Void ) { record() + _refreshLoadedChannels_channelCounts.mutate { $0.append(channelCount) } refreshLoadedChannelsResult?.invoke(with: completion) } + override func queryGroupedChannels( + limit: Int? = nil, + watch: Bool = false, + presence: Bool = false, + completion: @escaping @MainActor (Result) -> Void + ) { + _queryGroupedChannels_callCount.mutate { $0 += 1 } + if let result = queryGroupedChannels_result { + DispatchQueue.main.async { + completion(result) + } + } else { + super.queryGroupedChannels(limit: limit, watch: watch, presence: presence, completion: completion) + } + } + override func link( channel: ChatChannel, with query: ChannelListQuery, diff --git a/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift index 63e07cf2c08..5158322a91d 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift @@ -42,6 +42,30 @@ final class ChannelEndpoints_Tests: XCTestCase { } } + func test_groupedChannels_buildsCorrectly() { + let testCases: [(GroupedQueryChannelsRequestBody, Bool)] = [ + (.init(limit: 10, watch: true, presence: false), true), + (.init(limit: 10, watch: false, presence: true), true), + (.init(limit: 10, watch: true, presence: true), true), + (.init(limit: 10, watch: false, presence: false), false) + ] + + for (request, requiresConnectionId) in testCases { + let expectedEndpoint = Endpoint( + path: .groupedChannels, + method: .post, + queryItems: nil, + requiresConnectionId: requiresConnectionId, + body: request + ) + + let endpoint: Endpoint = .groupedChannels(request: request) + + XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) + XCTAssertEqual("channels/grouped", endpoint.path.value) + } + } + func test_channel_buildsCorrectly() { let cid = ChannelId(type: .livestream, id: "qwerty") diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift index 904fb0aae1a..4107ec20206 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift @@ -62,6 +62,123 @@ final class ChannelListPayload_Tests: XCTestCase { XCTAssertEqual(payload.channels.count, 2) } + func test_groupedQueryChannelsPayload_decodesGroupsMap() throws { + let channelId = ChannelId(type: .messaging, id: "bucket-channel") + let json = """ + { + "groups": { + "all": { + "channels": [ + { + "channel": { + "cid": "\(channelId.rawValue)", + "id": "\(channelId.id)", + "type": "\(channelId.type.rawValue)", + "name": "Support", + "image": "https://getstream.imgix.net/images/random_svg/stream_logo.svg", + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-02T00:00:00.000Z", + "frozen": false, + "disabled": false, + "config": { + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "quotes": true, + "uploads": true, + "url_enrichment": true, + "mutes": true, + "message_retention": "infinite", + "max_message_length": 5000, + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-02T00:00:00.000Z", + "commands": [] + }, + "own_capabilities": [], + "member_count": 0 + }, + "members": [], + "messages": [], + "pinned_messages": [], + "watchers": [], + "watcher_count": 0, + "read": [] + } + ], + "unread_channels": 1 + } + }, + "duration": "12ms" + } + """.data(using: .utf8)! + + let payload = try JSONDecoder.default.decode(GroupedQueryChannelsPayload.self, from: json) + + XCTAssertEqual(payload.groups.keys.sorted(), ["all"]) + XCTAssertEqual(payload.groups["all"]?.channels.map(\.channel.cid), [channelId]) + XCTAssertEqual(payload.groups["all"]?.unreadChannels, 1) + XCTAssertEqual(payload.duration, "12ms") + } + + func test_groupedQueryChannelsPayload_defaultsUnreadCountersWhenMissing() throws { + let channelId = ChannelId(type: .messaging, id: "bucket-channel") + let json = """ + { + "groups": { + "expired": { + "channels": [ + { + "channel": { + "cid": "\(channelId.rawValue)", + "id": "\(channelId.id)", + "type": "\(channelId.type.rawValue)", + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-02T00:00:00.000Z", + "frozen": false, + "disabled": false, + "config": { + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "quotes": true, + "uploads": true, + "url_enrichment": true, + "mutes": true, + "message_retention": "infinite", + "max_message_length": 5000, + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-02T00:00:00.000Z", + "commands": [] + }, + "own_capabilities": [], + "member_count": 0 + }, + "members": [], + "messages": [], + "pinned_messages": [], + "watchers": [], + "watcher_count": 0, + "read": [] + } + ] + } + }, + "duration": "12ms" + } + """.data(using: .utf8)! + + let payload = try JSONDecoder.default.decode(GroupedQueryChannelsPayload.self, from: json) + + XCTAssertEqual(payload.groups["expired"]?.channels.map(\.channel.cid), [channelId]) + XCTAssertEqual(payload.groups["expired"]?.unreadChannels, 0) + } + func saveChannelListPayload(_ payload: ChannelListPayload, database: DatabaseContainer_Spy, timeout: TimeInterval = 20) { let writeCompleted = expectation(description: "DB write complete") database.write({ session in diff --git a/Tests/StreamChatTests/ChatClient_Tests.swift b/Tests/StreamChatTests/ChatClient_Tests.swift index bcba030b081..7489eac33b6 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -264,6 +264,80 @@ final class ChatClient_Tests: XCTestCase { XCTAssert(testEnv.apiClient?.init_requestEncoder is RequestEncoder_Spy) } + func test_queryGroupedChannels_callsAPIClientAndReturnsGroupedChannels() throws { + let client = ChatClient.mock(config: inMemoryStorageConfig) + try client.databaseContainer.createCurrentUser() + let firstCid = ChannelId.unique + let secondCid = ChannelId.unique + let thirdCid = ChannelId.unique + + let request = GroupedQueryChannelsRequestBody(limit: 4, watch: true, presence: false) + let expectedEndpoint: Endpoint = .groupedChannels(request: request) + let payload = GroupedQueryChannelsPayload( + groups: [ + "all": .init( + channels: [dummyPayload(with: firstCid)], + unreadChannels: 1 + ), + "new": .init( + channels: [dummyPayload(with: secondCid)], + unreadChannels: 1 + ), + "current": .init( + channels: [dummyPayload(with: thirdCid)], + unreadChannels: 2 + ) + ], + duration: "12ms" + ) + + let expectation = self.expectation(description: "grouped query channels completes") + var receivedGroupedChannels: GroupedChannels? + var receivedError: Error? + + client.queryGroupedChannels(limit: 4, watch: true, presence: false) { result in + switch result { + case let .success(groupedChannels): + receivedGroupedChannels = groupedChannels + case let .failure(error): + receivedError = error + } + expectation.fulfill() + } + + XCTAssertEqual(client.mockAPIClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + client.mockAPIClient.test_simulateResponse(.success(payload)) + + waitForExpectations(timeout: defaultTimeout) + + XCTAssertNil(receivedError) + XCTAssertEqual(receivedGroupedChannels?.groups.keys.sorted(), ["all", "current", "new"]) + } + + func test_groupedChannelsGroup_normalizesUnreadTotalsFromChannels() { + let firstChannel = ChatChannel.mock( + cid: .unique, + unreadCount: .init(messages: 3, mentions: 0) + ) + let secondChannel = ChatChannel.mock( + cid: .unique, + unreadCount: .init(messages: 1, mentions: 0) + ) + let thirdChannel = ChatChannel.mock( + cid: .unique, + unreadCount: .noUnread + ) + + let group = GroupedChannelsGroup( + groupKey: "all", + channels: [firstChannel, secondChannel, thirdChannel], + unreadChannels: 0 + ) + + XCTAssertEqual(group.unreadChannels, 2) + XCTAssertEqual(group.groupKey, "all") + } + func test_disconnect_flushesRequestsQueue() throws { // Create a chat client let client = ChatClient( diff --git a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift index d08403255b9..0eb23bf3fba 100644 --- a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift @@ -244,6 +244,216 @@ final class ChannelListController_Tests: XCTestCase { AssertAsync.willBeEqual(completionCalledError as? TestError, testError) } + func test_prefill_skipsInitialSynchronizeRequest() { + let prefilledChannels: [ChatChannel] = [ + makePrefilledChannel(cid: .unique), + makePrefilledChannel(cid: .unique) + ] + + let prefillExpectation = expectation(description: "Prefill completes") + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) { error in + XCTAssertNil(error) + prefillExpectation.fulfill() + } + waitForExpectations(timeout: defaultTimeout) + + let synchronizeExpectation = expectation(description: "Synchronize completes") + controller.synchronize { error in + XCTAssertNil(error) + synchronizeExpectation.fulfill() + } + waitForExpectations(timeout: defaultTimeout) + + XCTAssertEqual(env.channelListUpdater?.prefill_queries.first?.filter.filterHash, query.filter.filterHash) + XCTAssertTrue(env.channelListUpdater?.update_queries.isEmpty ?? false) + XCTAssertEqual(Set(controller.channels.map(\.cid)), Set(prefilledChannels.map(\.cid))) + XCTAssertEqual(controller.state, .remoteDataFetched) + } + + func test_prefill_loadNextChannels_usesPrefilledChannelsCountAsOffset() { + query = .init(filter: .in(.members, values: [memberId]), pageSize: 2) + controller = ChatChannelListController(query: query, client: client, environment: env.environment) + + let prefilledChannels: [ChatChannel] = [ + makePrefilledChannel(cid: .unique), + makePrefilledChannel(cid: .unique), + makePrefilledChannel(cid: .unique) + ] + + let prefillExpectation = expectation(description: "Prefill completes") + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) { error in + XCTAssertNil(error) + prefillExpectation.fulfill() + } + waitForExpectations(timeout: defaultTimeout) + + controller.synchronize() + + let limit = 7 + controller.loadNextChannels(limit: limit) + + XCTAssertEqual( + env.channelListUpdater?.update_queries.first?.pagination, + .init(pageSize: limit, offset: prefilledChannels.count) + ) + } + + func test_prefill_whenChannelsCountIsLowerThanPageSize_doesNotBlockPagination() { + query = .init(filter: .in(.members, values: [memberId]), pageSize: 10) + controller = ChatChannelListController(query: query, client: client, environment: env.environment) + + let prefilledChannels: [ChatChannel] = [ + makePrefilledChannel(cid: .unique), + makePrefilledChannel(cid: .unique), + makePrefilledChannel(cid: .unique) + ] + + let prefillExpectation = expectation(description: "Prefill completes") + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) { error in + XCTAssertNil(error) + prefillExpectation.fulfill() + } + waitForExpectations(timeout: defaultTimeout) + + controller.synchronize() + + XCTAssertFalse(controller.hasLoadedAllPreviousChannels) + + controller.loadNextChannels() + + XCTAssertEqual( + env.channelListUpdater?.update_queries.first?.pagination, + .init(pageSize: query.pagination.pageSize, offset: prefilledChannels.count) + ) + } + + func test_prefill_refreshLoadedChannels_usesPrefilledChannelsCount() { + query = .init(filter: .in(.members, values: [memberId]), pageSize: 2) + controller = ChatChannelListController(query: query, client: client, environment: env.environment) + + let prefilledChannels: [ChatChannel] = [ + makePrefilledChannel(cid: .unique), + makePrefilledChannel(cid: .unique), + makePrefilledChannel(cid: .unique) + ] + + let prefillExpectation = expectation(description: "Prefill completes") + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) { error in + XCTAssertNil(error) + prefillExpectation.fulfill() + } + waitForExpectations(timeout: defaultTimeout) + + controller.synchronize() + + let refreshExpectation = expectation(description: "Refresh loaded channels completes") + env.channelListUpdater?.refreshLoadedChannelsResult = .success(Set(prefilledChannels.map(\.cid))) + controller.refreshLoadedChannels { result in + XCTAssertNil(result.error) + refreshExpectation.fulfill() + } + waitForExpectations(timeout: defaultTimeout) + + XCTAssertEqual( + env.channelListUpdater?.refreshLoadedChannels_channelCounts.first, + prefilledChannels.count + ) + } + + func test_prefill_whenPrefilledCountExceedsPageSize_observerExposesAllPrefilledChannels() { + query = .init(filter: .in(.members, values: [memberId]), pageSize: 2) + controller = ChatChannelListController(query: query, client: client, environment: env.environment) + + let prefilledChannels: [ChatChannel] = [ + makePrefilledChannel(cid: .unique), + makePrefilledChannel(cid: .unique), + makePrefilledChannel(cid: .unique) + ] + + let prefillExpectation = expectation(description: "Prefill completes") + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) { error in + XCTAssertNil(error) + prefillExpectation.fulfill() + } + waitForExpectations(timeout: defaultTimeout) + + controller.synchronize() + + // Without the fetchLimit bump this would be capped at 2 (pageSize). + AssertAsync.willBeEqual(controller.channels.count, prefilledChannels.count) + } + + func test_prefill_whenPrefilledCountIsBelowPageSize_observerStillReflectsPrefilledChannels() { + query = .init(filter: .in(.members, values: [memberId]), pageSize: 10) + controller = ChatChannelListController(query: query, client: client, environment: env.environment) + + let prefilledChannels: [ChatChannel] = [ + makePrefilledChannel(cid: .unique), + makePrefilledChannel(cid: .unique), + makePrefilledChannel(cid: .unique) + ] + + let prefillExpectation = expectation(description: "Prefill completes") + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) { error in + XCTAssertNil(error) + prefillExpectation.fulfill() + } + waitForExpectations(timeout: defaultTimeout) + + controller.synchronize() + + AssertAsync.willBeEqual(controller.channels.count, prefilledChannels.count) + } + + func test_prefill_replacesOnlyCurrentQueryLinks() throws { + let sharedCid = ChannelId.unique + let currentOnlyCid = ChannelId.unique + let replacementCid = ChannelId.unique + let otherQuery = ChannelListQuery(filter: .equal(.cid, to: sharedCid)) + + try client.databaseContainer.writeSynchronously { session in + try session.saveChannel( + payload: self.dummyPayload( + with: sharedCid, + members: [.dummy(user: .dummy(userId: self.memberId))] + ), + query: self.query, + cache: nil + ) + try session.saveChannel( + payload: self.dummyPayload(with: sharedCid), + query: otherQuery, + cache: nil + ) + try session.saveChannel( + payload: self.dummyPayload( + with: currentOnlyCid, + members: [.dummy(user: .dummy(userId: self.memberId))] + ), + query: self.query, + cache: nil + ) + } + + let prefillExpectation = expectation(description: "Prefill completes") + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: [makePrefilledChannel(cid: replacementCid)], unreadChannels: 0)) { error in + XCTAssertNil(error) + prefillExpectation.fulfill() + } + waitForExpectations(timeout: defaultTimeout) + + controller.synchronize() + + let otherController = ChatChannelListController( + query: otherQuery, + client: client, + environment: env.environment + ) + + XCTAssertEqual(controller.channels.map(\.cid), [replacementCid]) + XCTAssertEqual(otherController.channels.map(\.cid), [sharedCid]) + } + /// This test simulates a bug where the `channels` field was not updated if it wasn't /// touched before calling synchronize. func test_channelsAreFetched_afterCallingSynchronize() throws { @@ -601,6 +811,62 @@ final class ChannelListController_Tests: XCTestCase { AssertAsync.willBeEqual(env.channelListUpdater?.unlink_callCount, 1) } + func test_didReceiveEvent_whenMessageNewEvent_whenFilterDoesNotMatch_shouldUnlinkChannelFromQuery() throws { + let filter: (ChatChannel) -> Bool = { channel in + channel.memberCount == 1 + } + setupControllerWithFilter(filter) + + let cid: ChannelId = .unique + writeAndWaitForChannelsUpdates { session in + try session.saveChannel( + payload: self.dummyPayload( + with: cid, + members: [.dummy(user: .dummy(userId: self.memberId))] + ), + query: self.query, + cache: nil + ) + } + + let event = makeMessageNewEvent(with: .mock(cid: cid, memberCount: 4)) + let eventExpectation = XCTestExpectation(description: "Event processed") + controller.client.eventNotificationCenter.process(event) { + eventExpectation.fulfill() + } + wait(for: [eventExpectation], timeout: defaultTimeout) + + AssertAsync.willBeEqual(env.channelListUpdater?.unlink_callCount, 1) + } + + func test_didReceiveEvent_whenNotificationMessageNewEvent_whenFilterDoesNotMatch_shouldUnlinkChannelFromQuery() throws { + let filter: (ChatChannel) -> Bool = { channel in + channel.memberCount == 1 + } + setupControllerWithFilter(filter) + + let cid: ChannelId = .unique + writeAndWaitForChannelsUpdates { session in + try session.saveChannel( + payload: self.dummyPayload( + with: cid, + members: [.dummy(user: .dummy(userId: self.memberId))] + ), + query: self.query, + cache: nil + ) + } + + let event = makeNotificationMessageNewEvent(with: .mock(cid: cid, memberCount: 4)) + let eventExpectation = XCTestExpectation(description: "Event processed") + controller.client.eventNotificationCenter.process(event) { + eventExpectation.fulfill() + } + wait(for: [eventExpectation], timeout: defaultTimeout) + + AssertAsync.willBeEqual(env.channelListUpdater?.unlink_callCount, 1) + } + func test_didReceiveEvent_whenChannelUpdatedEvent__whenFilterMatches_shouldNotUnlinkChannelFromQuery() throws { let filter: (ChatChannel) -> Bool = { channel in channel.memberCount == 4 @@ -2116,6 +2382,25 @@ final class ChannelListController_Tests: XCTestCase { ) } + private func makePrefilledChannel(cid: ChannelId) -> ChatChannel { + try! client.databaseContainer.writeSynchronously { session in + try session.saveChannel( + payload: self.dummyPayload( + with: cid, + members: [.dummy(user: .dummy(userId: self.memberId))] + ), + query: nil, + cache: nil + ) + } + return .mock( + cid: cid, + lastActiveMembers: [.mock(id: memberId)], + membership: .mock(id: memberId), + memberCount: 1 + ) + } + private func setupControllerWithFilter(_ filter: @escaping (ChatChannel) -> Bool) { // Prepare controller controller = ChatChannelListController( diff --git a/Tests/StreamChatTests/Database/DTOs/CurrentUserDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/CurrentUserDTO_Tests.swift index c3a8a9ebb08..24185fbd5b1 100644 --- a/Tests/StreamChatTests/Database/DTOs/CurrentUserDTO_Tests.swift +++ b/Tests/StreamChatTests/Database/DTOs/CurrentUserDTO_Tests.swift @@ -182,6 +182,22 @@ final class CurrentUserModelDTO_Tests: XCTestCase { XCTAssertEqual(currentUser?.unreadCount.threads, 3) } + func test_saveCurrentUserGroupedUnreadChannels_isStoredAndLoadedFromDB() throws { + let payload = CurrentUserPayload.dummy(userPayload: .dummy(userId: .unique, role: .admin)) + let groupedUnreadChannels: GroupedUnreadChannels = [ + "direct": 2, + "support": 5 + ] + + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: payload) + try session.saveCurrentUserGroupedUnreadChannels(groupedUnreadChannels) + } + + let loadedCurrentUser = try XCTUnwrap(database.viewContext.currentUser?.asModel()) + XCTAssertEqual(loadedCurrentUser.groupedUnreadChannels, groupedUnreadChannels) + } + func test_saveCurrentUser_removesChannelMutesNotInPayload() throws { // GIVEN let userPayload: UserPayload = .dummy(userId: .unique) diff --git a/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift b/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift index 536125d21c8..18dde7c48fa 100644 --- a/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift +++ b/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift @@ -143,6 +143,28 @@ final class DatabaseSession_Tests: XCTestCase { XCTAssertEqual(loadedChannel.messageCount, 5) } + func test_eventPayloadGroupedUnreadChannels_isSavedToDatabase() throws { + let currentUserPayload = CurrentUserPayload.dummy(userPayload: .dummy(userId: .unique, role: .admin)) + let groupedUnreadChannels: GroupedUnreadChannels = [ + "direct": 1, + "team": 4 + ] + + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: currentUserPayload) + try session.saveEvent(payload: EventPayload( + eventType: .messageNew, + cid: .unique, + user: .dummy(userId: .unique), + message: .dummy(messageId: .unique, authorUserId: .unique), + groupedUnreadChannels: groupedUnreadChannels, + createdAt: .unique + )) + } + + XCTAssertEqual(database.viewContext.currentUser?.groupedUnreadChannels, groupedUnreadChannels) + } + func test_deleteMessage() throws { let channelId: ChannelId = .unique let messageId: MessageId = .unique @@ -769,6 +791,106 @@ final class DatabaseSession_Tests: XCTestCase { XCTAssertEqual(channelDTO.previewMessage?.id, newMessage.id) } + func test_saveEvent_whenMessageNewEventComesWithoutChannelMessageCount_keepsExistingChannelMessageCount() throws { + let existingMessage: MessagePayload = .dummy( + messageId: .unique, + authorUserId: .unique + ) + let channel: ChannelPayload = .dummy( + channel: .dummy(messageCount: 1), + messages: [existingMessage] + ) + + try database.writeSynchronously { session in + try session.saveChannel(payload: channel) + } + + let newMessage: MessagePayload = .dummy( + messageId: .unique, + authorUserId: .unique, + createdAt: existingMessage.createdAt.addingTimeInterval(10) + ) + + let messageNewEvent = EventPayload( + eventType: .messageNew, + cid: channel.channel.cid, + channel: channel.channel, + message: newMessage + ) + + try database.writeSynchronously { session in + try session.saveEvent(payload: messageNewEvent) + } + + let channelDTO = try XCTUnwrap(database.viewContext.channel(cid: channel.channel.cid)) + XCTAssertEqual(channelDTO.messageCount?.intValue, 1) + } + + func test_saveEvent_whenNotificationMessageNewEventComesWithoutChannelMessageCount_keepsExistingChannelMessageCount() throws { + let existingMessage: MessagePayload = .dummy( + messageId: .unique, + authorUserId: .unique + ) + let channel: ChannelPayload = .dummy( + channel: .dummy(messageCount: 1), + messages: [existingMessage] + ) + + try database.writeSynchronously { session in + try session.saveChannel(payload: channel) + } + + let newMessage: MessagePayload = .dummy( + messageId: .unique, + authorUserId: .unique, + createdAt: existingMessage.createdAt.addingTimeInterval(10) + ) + + let messageNewEvent = EventPayload( + eventType: .notificationMessageNew, + cid: channel.channel.cid, + channel: channel.channel, + message: newMessage + ) + + try database.writeSynchronously { session in + try session.saveEvent(payload: messageNewEvent) + } + + let channelDTO = try XCTUnwrap(database.viewContext.channel(cid: channel.channel.cid)) + XCTAssertEqual(channelDTO.messageCount?.intValue, 1) + } + + func test_saveEvent_whenMessageNewEventComesWithoutChannelMessageCountAndStoredCountIsMissing_keepsMessageCountNil() throws { + let channel: ChannelPayload = .dummy( + channel: .dummy(messageCount: nil), + messages: [] + ) + + try database.writeSynchronously { session in + try session.saveChannel(payload: channel) + } + + let newMessage: MessagePayload = .dummy( + messageId: .unique, + authorUserId: .unique + ) + + let messageNewEvent = EventPayload( + eventType: .messageNew, + cid: channel.channel.cid, + channel: channel.channel, + message: newMessage + ) + + try database.writeSynchronously { session in + try session.saveEvent(payload: messageNewEvent) + } + + let channelDTO = try XCTUnwrap(database.viewContext.channel(cid: channel.channel.cid)) + XCTAssertNil(channelDTO.messageCount) + } + func test_saveEvent_whenNotificationMessageNewEventComes_whenUpdateIsOlderThanCurrentPreview_DoesNotUpdateChannelPreview() throws { // GIVEN let previousPreviewMessage: MessagePayload = .dummy( diff --git a/Tests/StreamChatTests/Query/ChannelListFilterScope_Tests.swift b/Tests/StreamChatTests/Query/ChannelListFilterScope_Tests.swift index 9be2782ddd7..b9e7d63e79b 100644 --- a/Tests/StreamChatTests/Query/ChannelListFilterScope_Tests.swift +++ b/Tests/StreamChatTests/Query/ChannelListFilterScope_Tests.swift @@ -2,8 +2,10 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import CoreData import Foundation @testable import StreamChat +@testable import StreamChatTestTools import XCTest final class ChannelListFilterScope_Tests: XCTestCase { @@ -110,4 +112,93 @@ final class ChannelListFilterScope_Tests: XCTestCase { XCTAssertEqual(query.debugDescription, "Filter: members IN [\"theid\"] | Sort: [cid:-1]") } + + func test_messageCount_filter_fallsBackToMessagesCountWhenStoredValueIsMissing() throws { + let database = DatabaseContainer_Spy() + + // A: stored messageCount = 10, zero cached messages. + let cidA = ChannelId.unique + let channelA: ChannelPayload = .dummy( + channel: .dummy(cid: cidA, messageCount: 10), + messages: [] + ) + + // B: backend omitted messageCount, 5 messages cached locally. + let cidB = ChannelId.unique + let messagesB = (0..<5).map { _ in MessagePayload.dummy(messageId: .unique, authorUserId: .unique) } + let channelB: ChannelPayload = .dummy( + channel: .dummy(cid: cidB, messageCount: nil), + messages: messagesB + ) + + // C: stored messageCount = 0, zero cached messages. + let cidC = ChannelId.unique + let channelC: ChannelPayload = .dummy( + channel: .dummy(cid: cidC, messageCount: 0), + messages: [] + ) + + try database.writeSynchronously { session in + try session.saveChannel(payload: channelA, query: nil, cache: nil) + try session.saveChannel(payload: channelB, query: nil, cache: nil) + try session.saveChannel(payload: channelC, query: nil, cache: nil) + } + + // `> 3` matches A via stored branch (10) and B via messages.@count branch (5). + let greaterPredicate = try XCTUnwrap( + Filter.greater(.messageCount, than: 3).predicate + ) + let greaterRequest = NSFetchRequest(entityName: ChannelDTO.entityName) + greaterRequest.predicate = greaterPredicate + let greaterResult = try database.viewContext.fetch(greaterRequest) + XCTAssertEqual(Set(greaterResult.map(\.cid)), [cidA.rawValue, cidB.rawValue]) + + // `== 5` matches only B (stored for A is 10; stored for B is nil so we fall back to messages.@count = 5). + let equalPredicate = try XCTUnwrap( + Filter.equal(.messageCount, to: 5).predicate + ) + let equalRequest = NSFetchRequest(entityName: ChannelDTO.entityName) + equalRequest.predicate = equalPredicate + let equalResult = try database.viewContext.fetch(equalRequest) + XCTAssertEqual(Set(equalResult.map(\.cid)), [cidB.rawValue]) + } + + func test_messageCount_filter_usesMessagesCountAfterEventWhenStoredValueIsMissing() throws { + let database = DatabaseContainer_Spy() + let cid = ChannelId.unique + + let channel: ChannelPayload = .dummy( + channel: .dummy(cid: cid, messageCount: nil), + messages: [] + ) + + try database.writeSynchronously { session in + try session.saveChannel(payload: channel, query: nil, cache: nil) + } + + let newMessage: MessagePayload = .dummy( + messageId: .unique, + authorUserId: .unique + ) + let newMessageEvent = EventPayload( + eventType: .messageNew, + cid: cid, + channel: channel.channel, + message: newMessage + ) + try database.writeSynchronously { session in + try session.saveEvent(payload: newMessageEvent) + } + + let storedChannel = try XCTUnwrap(database.viewContext.channel(cid: cid)) + XCTAssertNil(storedChannel.messageCount) + + let lessOrEqualPredicate = try XCTUnwrap( + Filter.lessOrEqual(.messageCount, than: 1).predicate + ) + let request = NSFetchRequest(entityName: ChannelDTO.entityName) + request.predicate = lessOrEqualPredicate + let result = try database.viewContext.fetch(request) + XCTAssertEqual(Set(result.map(\.cid)), [cid.rawValue]) + } } diff --git a/Tests/StreamChatTests/Query/Sorting/ListDatabaseObserver+Sorting_Tests.swift b/Tests/StreamChatTests/Query/Sorting/ListDatabaseObserver+Sorting_Tests.swift index 253e379dbc3..daed82e4e1c 100644 --- a/Tests/StreamChatTests/Query/Sorting/ListDatabaseObserver+Sorting_Tests.swift +++ b/Tests/StreamChatTests/Query/Sorting/ListDatabaseObserver+Sorting_Tests.swift @@ -354,7 +354,7 @@ final class ListDatabaseObserver_Sorting_Tests: XCTestCase { ) } - guard let queryDTO = session.channelListQuery(filterHash: self.query.filter.filterHash) else { + guard let queryDTO = session.channelListQuery(self.query) else { return } for channel in channels { diff --git a/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift b/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift index 8f42bba7f00..da16554d605 100644 --- a/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift +++ b/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift @@ -227,6 +227,90 @@ class SyncRepository_Tests: XCTestCase { XCTAssertCall("runQueuedRequests(completion:)", on: offlineRequestsRepository, times: 1) } + func test_syncLocalState_prefilledController_callsQueryGroupedChannelsAndSkipsRefresh() throws { + let cid = ChannelId.unique + try prepareForSyncLocalStorage( + createUser: true, + lastSynchedEventDate: Date().addingTimeInterval(-3600), + createChannel: true, + cid: cid + ) + + var prefilledQuery = ChannelListQuery(filter: .exists(.cid)) + prefilledQuery.groupKey = "all" + let chatListController = ChatChannelListController_Mock(query: prefilledQuery, client: client) + chatListController.state_mock = .remoteDataFetched + chatListController.channels_mock = [.mock(cid: cid)] + repository.startTrackingChannelListController(chatListController) + let refreshedGroup = GroupedChannelsGroup(groupKey: "all", channels: [.mock(cid: cid)], unreadChannels: 0) + channelListUpdater.queryGroupedChannels_result = .success(.init(groups: ["all": refreshedGroup])) + + waitForSyncLocalStateRun() + + XCTAssertEqual(channelListUpdater.queryGroupedChannels_callCount, 1) + XCTAssertNotCall("refreshLoadedChannels(completion:)", on: chatListController) + // The grouped response's "all" group is forwarded to the prefilled controller's prefill(group:). + XCTAssertEqual(chatListController.prefill_groups.map(\.groupKey), ["all"]) + // The controller's cid was marked as synched by the grouped op, so /sync is skipped. + XCTAssertEqual(apiClient.request_allRecordedCalls.count, 0) + } + + func test_syncLocalState_mixedControllers_callsGroupedOnceAndRefreshesOnlyStandard() throws { + let prefilledCid = ChannelId.unique + let standardCid = ChannelId.unique + try prepareForSyncLocalStorage( + createUser: true, + lastSynchedEventDate: Date().addingTimeInterval(-3600), + createChannel: true, + cid: prefilledCid + ) + + var prefilledQuery = ChannelListQuery(filter: .exists(.cid)) + prefilledQuery.groupKey = "current" + let prefilledController = ChatChannelListController_Mock(query: prefilledQuery, client: client) + prefilledController.state_mock = .remoteDataFetched + prefilledController.channels_mock = [.mock(cid: prefilledCid)] + repository.startTrackingChannelListController(prefilledController) + + let standardController = ChatChannelListController_Mock(query: .init(filter: .in(.cid, values: [standardCid])), client: client) + standardController.state_mock = .remoteDataFetched + standardController.channels_mock = [.mock(cid: standardCid)] + standardController.refreshLoadedChannelsResult = .success(Set([standardCid])) + repository.startTrackingChannelListController(standardController) + + let refreshedGroup = GroupedChannelsGroup(groupKey: "current", channels: [.mock(cid: prefilledCid)], unreadChannels: 0) + channelListUpdater.queryGroupedChannels_result = .success(.init(groups: ["current": refreshedGroup])) + + waitForSyncLocalStateRun() + + XCTAssertEqual(channelListUpdater.queryGroupedChannels_callCount, 1) + XCTAssertNotCall("refreshLoadedChannels(completion:)", on: prefilledController) + XCTAssertCall("refreshLoadedChannels(completion:)", on: standardController, times: 1) + XCTAssertEqual(prefilledController.prefill_groups.map(\.groupKey), ["current"]) + XCTAssertEqual(standardController.prefill_groups.count, 0) + } + + func test_syncLocalState_noPrefilledControllers_doesNotCallQueryGroupedChannels() throws { + let cid = ChannelId.unique + try prepareForSyncLocalStorage( + createUser: true, + lastSynchedEventDate: Date().addingTimeInterval(-3600), + createChannel: true, + cid: cid + ) + + let chatListController = ChatChannelListController_Mock(query: .init(filter: .exists(.cid)), client: client) + chatListController.state_mock = .remoteDataFetched + chatListController.channels_mock = [.mock(cid: cid)] + chatListController.refreshLoadedChannelsResult = .success(Set([cid])) + repository.startTrackingChannelListController(chatListController) + + waitForSyncLocalStateRun() + + XCTAssertEqual(channelListUpdater.queryGroupedChannels_callCount, 0) + XCTAssertCall("refreshLoadedChannels(completion:)", on: chatListController, times: 1) + } + func test_syncLocalState_ignoresTheCooldown() throws { let lastSyncDate = Date() let cid = ChannelId.unique diff --git a/Tests/StreamChatTests/WebSocketClient/Events/MessageEvents_Tests.swift b/Tests/StreamChatTests/WebSocketClient/Events/MessageEvents_Tests.swift index f3f35d28f70..406fad84d88 100644 --- a/Tests/StreamChatTests/WebSocketClient/Events/MessageEvents_Tests.swift +++ b/Tests/StreamChatTests/WebSocketClient/Events/MessageEvents_Tests.swift @@ -43,6 +43,36 @@ final class MessageEvents_Tests: XCTestCase { XCTAssertNil(event?.unreadCount) } + func test_messageNewEventDTO_toDomainEvent_includesGroupedUnreadChannels() throws { + let groupedUnreadChannels: GroupedUnreadChannels = [ + "priority": 3, + "social": 7 + ] + let session = DatabaseContainer_Spy(kind: .inMemory).viewContext + let userPayload = UserPayload.dummy(userId: .unique) + let messagePayload = MessagePayload.dummy(messageId: .unique, authorUserId: userPayload.id) + let cid: ChannelId = .unique + let eventPayload = EventPayload( + eventType: .messageNew, + cid: cid, + user: userPayload, + message: messagePayload, + unreadCount: .init(channels: 4, messages: 9, threads: 2), + groupedUnreadChannels: groupedUnreadChannels, + createdAt: .unique + ) + + try session.saveUser(payload: userPayload) + _ = try session.saveChannel(payload: .dummy(cid: cid), query: nil, cache: nil) + _ = try session.saveMessage(payload: messagePayload, for: cid, cache: nil) + _ = try session.saveCurrentUser(payload: .dummy(userPayload: .dummy(userId: .unique), unreadCount: eventPayload.unreadCount)) + try session.saveEvent(payload: eventPayload) + + let dto = try MessageNewEventDTO(from: eventPayload) + let event = try XCTUnwrap(dto.toDomainEvent(session: session) as? MessageNewEvent) + XCTAssertEqual(event.groupedUnreadChannels, groupedUnreadChannels) + } + func test_updated() throws { let json = XCTestCase.mockData(fromJSONFile: "MessageUpdated") let event = try eventDecoder.decode(from: json) as? MessageUpdatedEventDTO diff --git a/Tests/StreamChatTests/WebSocketClient/Events/NotificationEvents_Tests.swift b/Tests/StreamChatTests/WebSocketClient/Events/NotificationEvents_Tests.swift index 372180eae8e..96fc8bdd1e6 100644 --- a/Tests/StreamChatTests/WebSocketClient/Events/NotificationEvents_Tests.swift +++ b/Tests/StreamChatTests/WebSocketClient/Events/NotificationEvents_Tests.swift @@ -54,6 +54,65 @@ final class NotificationsEvents_Tests: XCTestCase { XCTAssertEqual(event?.unreadCount, .init(channels: 8, messages: 55, threads: 10)) } + func test_markRead_decodesGroupedUnreadChannels() throws { + let json = """ + { + "type": "notification.mark_read", + "cid": "messaging:general", + "channel_type": "messaging", + "channel_id": "general", + "channel": { + "id": "general", + "type": "messaging", + "cid": "messaging:general", + "created_at": "2020-07-21T14:47:57Z", + "updated_at": "2020-07-21T14:47:57Z", + "frozen": false, + "disabled": false, + "config": { + "created_at": "2020-07-21T14:47:57Z", + "updated_at": "2020-07-21T14:47:57Z", + "reactions": true, + "typing_events": true, + "read_events": true, + "connect_events": true, + "uploads": true, + "replies": true, + "quotes": true, + "search": false, + "mutes": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "commands": [] + } + }, + "user": { + "id": "steep-moon-9", + "role": "user", + "created_at": "2020-07-21T14:47:57Z", + "updated_at": "2020-07-21T14:47:57Z", + "last_active": "2020-07-21T14:47:57Z", + "online": true, + "banned": false + }, + "created_at": "2020-07-21T14:47:57Z", + "unread_channels": 8, + "total_unread_count": 55, + "grouped_unread_channels": { + "direct": 2, + "vip": 5 + } + } + """.data(using: .utf8)! + + let event = try eventDecoder.decode(from: json) as? NotificationMarkReadEventDTO + let groupedUnreadChannels = try XCTUnwrap(event?.payload.groupedUnreadChannels) + XCTAssertEqual(groupedUnreadChannels["direct"], 2) + XCTAssertEqual(groupedUnreadChannels["vip"], 5) + XCTAssertEqual(groupedUnreadChannels.count, 2) + } + func test_markUnread() throws { let json = XCTestCase.mockData(fromJSONFile: "NotificationMarkUnread") let event = try eventDecoder.decode(from: json) as? NotificationMarkUnreadEventDTO @@ -200,11 +259,13 @@ final class NotificationsEvents_Tests: XCTestCase { let session = DatabaseContainer_Spy(kind: .inMemory).viewContext // Create event payload + let groupedUnreadChannels: GroupedUnreadChannels = ["direct": 4, "support": 1] let eventPayload = EventPayload( eventType: .notificationMarkRead, cid: .unique, user: .dummy(userId: .unique), unreadCount: .init(channels: .unique, messages: .unique, threads: .unique), + groupedUnreadChannels: groupedUnreadChannels, createdAt: .unique, lastReadMessageId: "lastRead" ) @@ -218,12 +279,14 @@ final class NotificationsEvents_Tests: XCTestCase { // Save event to database try session.saveUser(payload: eventPayload.user!) _ = try session.saveCurrentUser(payload: .dummy(userPayload: .dummy(userId: .unique), unreadCount: eventPayload.unreadCount)) + try session.saveEvent(payload: eventPayload) // Assert event can be created and has correct fields let event = try XCTUnwrap(dto.toDomainEvent(session: session) as? NotificationMarkReadEvent) XCTAssertEqual(event.user.id, eventPayload.user?.id) XCTAssertEqual(event.cid, eventPayload.cid) XCTAssert(event.unreadCount?.isEqual(toPayload: eventPayload.unreadCount) == true) + XCTAssertEqual(event.groupedUnreadChannels, groupedUnreadChannels) XCTAssertEqual(event.lastReadMessageId, eventPayload.lastReadMessageId) XCTAssertEqual(event.createdAt, eventPayload.createdAt) } @@ -234,11 +297,13 @@ final class NotificationsEvents_Tests: XCTestCase { let lastReadAt = Date() // Create event payload + let groupedUnreadChannels: GroupedUnreadChannels = ["mentions": 2, "team": 6] let eventPayload = EventPayload( eventType: .notificationMarkRead, cid: .unique, user: .dummy(userId: .unique), unreadCount: .init(channels: .unique, messages: .unique, threads: .unique), + groupedUnreadChannels: groupedUnreadChannels, createdAt: .unique, firstUnreadMessageId: "Hello", lastReadAt: lastReadAt, @@ -255,6 +320,7 @@ final class NotificationsEvents_Tests: XCTestCase { // Save event to database try session.saveUser(payload: eventPayload.user!) _ = try session.saveCurrentUser(payload: .dummy(userPayload: .dummy(userId: .unique), unreadCount: eventPayload.unreadCount)) + try session.saveEvent(payload: eventPayload) // Assert event can be created and has correct fields let event = try XCTUnwrap(dto.toDomainEvent(session: session) as? NotificationMarkUnreadEvent) @@ -264,6 +330,7 @@ final class NotificationsEvents_Tests: XCTestCase { XCTAssertEqual(event.firstUnreadMessageId, eventPayload.firstUnreadMessageId) XCTAssertEqual(event.lastReadAt, eventPayload.lastReadAt) XCTAssertEqual(event.lastReadMessageId, eventPayload.lastReadMessageId) + XCTAssertEqual(event.groupedUnreadChannels, groupedUnreadChannels) XCTAssertEqual(event.unreadMessagesCount, eventPayload.unreadMessagesCount) } @@ -498,13 +565,17 @@ final class NotificationsEvents_Tests: XCTestCase { let session = DatabaseContainer_Spy(kind: .inMemory).viewContext // Create event payload + let groupedUnreadChannels: GroupedUnreadChannels = ["deleted": 8] let eventPayload = EventPayload( eventType: .notificationChannelDeleted, cid: .unique, channel: .dummy(cid: .unique), + groupedUnreadChannels: groupedUnreadChannels, createdAt: .unique ) + _ = try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .admin)) + try session.saveEvent(payload: eventPayload) // Save event to database _ = try session.saveChannel(payload: eventPayload.channel!, query: nil, cache: nil) @@ -515,5 +586,6 @@ final class NotificationsEvents_Tests: XCTestCase { let event = try XCTUnwrap(dto.toDomainEvent(session: session) as? NotificationChannelDeletedEvent) XCTAssertEqual(event.cid, eventPayload.cid) XCTAssertEqual(event.createdAt, eventPayload.createdAt) + XCTAssertEqual(event.groupedUnreadChannels, groupedUnreadChannels) } } diff --git a/Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift b/Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift index 4ab687ff847..09a1b68e9eb 100644 --- a/Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift +++ b/Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift @@ -112,7 +112,7 @@ final class ChannelListUpdater_Tests: XCTestCase { // Assert the data is stored in the DB var queryDTO: ChannelListQueryDTO? { - database.viewContext.channelListQuery(filterHash: query.filter.filterHash) + database.viewContext.channelListQuery(query) } AssertAsync { Assert.willBeTrue(queryDTO != nil) @@ -133,9 +133,7 @@ final class ChannelListUpdater_Tests: XCTestCase { } var channelsFromQuery: [ChatChannel] { - database.viewContext.channelListQuery( - filterHash: query.filter.filterHash - )?.channels.compactMap { try? $0.asModel() } ?? [] + database.viewContext.channelListQuery(query)?.channels.compactMap { try? $0.asModel() } ?? [] } XCTAssertEqual(channelsFromQuery.count, 3) @@ -169,9 +167,7 @@ final class ChannelListUpdater_Tests: XCTestCase { } var channelsFromQuery: [ChatChannel] { - database.viewContext.channelListQuery( - filterHash: query.filter.filterHash - )?.channels.compactMap { try? $0.asModel() } ?? [] + database.viewContext.channelListQuery(query)?.channels.compactMap { try? $0.asModel() } ?? [] } XCTAssertEqual(channelsFromQuery.count, 3) @@ -205,9 +201,7 @@ final class ChannelListUpdater_Tests: XCTestCase { } var channelsFromQuery: [ChatChannel] { - database.viewContext.channelListQuery( - filterHash: query.filter.filterHash - )?.channels.compactMap { try? $0.asModel() } ?? [] + database.viewContext.channelListQuery(query)?.channels.compactMap { try? $0.asModel() } ?? [] } XCTAssertEqual(channelsFromQuery.count, 3) @@ -409,9 +403,7 @@ final class ChannelListUpdater_Tests: XCTestCase { waitForExpectations(timeout: defaultTimeout) var channelsInQuery: [ChatChannel] { - database.viewContext.channelListQuery( - filterHash: query.filter.filterHash - )?.channels.compactMap { try? $0.asModel() } ?? [] + database.viewContext.channelListQuery(query)?.channels.compactMap { try? $0.asModel() } ?? [] } XCTAssertTrue(channelsInQuery.contains(where: { $0.cid == channel.cid })) @@ -431,9 +423,7 @@ final class ChannelListUpdater_Tests: XCTestCase { } var channelsInQuery: [ChatChannel] { - database.viewContext.channelListQuery( - filterHash: query.filter.filterHash - )?.channels.compactMap { try? $0.asModel() } ?? [] + database.viewContext.channelListQuery(query)?.channels.compactMap { try? $0.asModel() } ?? [] } XCTAssertTrue(channelsInQuery.contains(where: { $0.cid == channel.cid }))