From dcf4bf0548f02b30ea5fcc02c7c4d7e520e51203 Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Mon, 13 Apr 2026 17:42:51 +0200 Subject: [PATCH 01/31] Initial implementation of grouped channels --- CHANGELOG.md | 4 + .../Endpoints/ChannelEndpoints.swift | 12 + .../EndpointPath+OfflineRequest.swift | 2 +- .../APIClient/Endpoints/EndpointPath.swift | 2 + .../Payloads/ChannelListPayload.swift | 60 +++ Sources/StreamChat/ChatClient.swift | 67 ++++ .../ChannelListController.swift | 53 ++- .../StreamChat/Database/DTOs/ChannelDTO.swift | 14 + .../StreamChat/Database/DatabaseSession.swift | 3 + .../Workers/ChannelListLinker.swift | 19 +- .../Workers/ChannelListUpdater.swift | 362 ++++++++++++++++++ .../Spy/ChannelListUpdater_Spy.swift | 19 + .../Endpoints/ChannelEndpoints_Tests.swift | 24 ++ Tests/StreamChatTests/ChatClient_Tests.swift | 59 +++ .../ChannelListController_Tests.swift | 230 +++++++++++ .../Database/DatabaseSession_Tests.swift | 194 ++++++++++ 16 files changed, 1112 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76cd8e56669..28a33a2b381 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming +### ✅ Added +- Add `ChatChannelListController.prefill(channels:completion:)` for priming controller-local channel data before the first synchronize call while preserving normal pagination, observation, and offline refresh behavior +- Add `ChatClient.groupedQueryChannels(limit:watch:presence:)` to fetch grouped channel buckets as `[[ChatChannel]]` in `all`, `new`, `current`, `expired` order + ### 🔄 Changed # [5.1.0](https://github.com/GetStream/stream-chat-swift/releases/tag/5.1.0) diff --git a/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift index 18d6b7a7434..002a6a3426f 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 1eeee0927ae..db1a5f5828c 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift @@ -25,6 +25,66 @@ extension ChannelListPayload: Decodable { } } +struct GroupedQueryChannelsRequestBody: Encodable { + let limit: Int? + let watch: Bool + let presence: Bool +} + +struct GroupedQueryChannelsPayload { + let all: GroupedQueryChannelsBucketPayload + let new: GroupedQueryChannelsBucketPayload + let current: GroupedQueryChannelsBucketPayload + let expired: GroupedQueryChannelsBucketPayload + let duration: String +} + +extension GroupedQueryChannelsPayload: Decodable { + enum CodingKeys: String, CodingKey { + case all + case new + case current + case expired + case duration + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.init( + all: try container.decode(GroupedQueryChannelsBucketPayload.self, forKey: .all), + new: try container.decode(GroupedQueryChannelsBucketPayload.self, forKey: .new), + current: try container.decode(GroupedQueryChannelsBucketPayload.self, forKey: .current), + expired: try container.decode(GroupedQueryChannelsBucketPayload.self, forKey: .expired), + duration: try container.decode(String.self, forKey: .duration) + ) + } +} + +struct GroupedQueryChannelsBucketPayload { + let channels: [ChannelPayload] + let unreadCount: Int + let unreadChannels: Int +} + +extension GroupedQueryChannelsBucketPayload: Decodable { + enum CodingKeys: String, CodingKey { + case channels + case unreadCount = "unread_count" + case unreadChannels = "unread_channels" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.init( + channels: try container.decodeArrayIgnoringFailures([ChannelPayload].self, forKey: .channels), + unreadCount: try container.decode(Int.self, forKey: .unreadCount), + unreadChannels: try container.decode(Int.self, forKey: .unreadChannels) + ) + } +} + struct ChannelPayload { let channel: ChannelDetailPayload diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index 108ebf2a041..b81092ac1bb 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -500,6 +500,51 @@ public class ChatClient: @unchecked Sendable { authenticationRepository.setToken(token: token, completeTokenWaiters: true) } + /// Loads grouped channel buckets and returns them in the following order: + /// `all`, `new`, `current`, `expired`. + /// + /// The response is converted to `ChatChannel` models without persisting the data locally. + public func groupedQueryChannels( + limit: Int? = nil, + watch: Bool = false, + presence: Bool = false, + completion: @escaping (Result<[[ChatChannel]], Error>) -> Void + ) { + let request = GroupedQueryChannelsRequestBody( + limit: limit, + watch: watch, + presence: presence + ) + let endpoint: Endpoint = .groupedChannels(request: request) + + apiClient.request(endpoint: endpoint) { [databaseContainer] result in + switch result { + case let .success(payload): + databaseContainer.write(converting: { session in + try Self.groupedChannels(from: payload, session: session) + }, completion: completion) + case let .failure(error): + completion(.failure(error)) + } + } + } + + /// Loads grouped channel buckets and returns them in the following order: + /// `all`, `new`, `current`, `expired`. + /// + /// The response is converted to `ChatChannel` models without persisting the data locally. + public func groupedQueryChannels( + limit: Int? = nil, + watch: Bool = false, + presence: Bool = false + ) async throws -> [[ChatChannel]] { + try await withCheckedThrowingContinuation { continuation in + groupedQueryChannels(limit: limit, watch: watch, presence: presence) { result in + continuation.resume(with: result) + } + } + } + /// Disconnects the chat client from the chat servers. No further updates from the servers /// are received. public func disconnect(completion: @escaping @MainActor () -> Void) { @@ -801,6 +846,28 @@ extension ChatClient: ConnectionDetailsProviderDelegate { } extension ChatClient { + private static func groupedChannels( + from payload: GroupedQueryChannelsPayload, + session: DatabaseSession + ) throws -> [[ChatChannel]] { + let buckets = [ + payload.all.channels, + payload.new.channels, + payload.current.channels, + payload.expired.channels + ] + + let models = try buckets.map { channels in + try channels.map { channelPayload in + let dto = try session.saveChannel(payload: channelPayload) + return try dto.asModel() + } + } + + (session as? NSManagedObjectContext)?.rollback() + return models + } + func backgroundWorker(of type: T.Type) throws -> T { if let worker = backgroundWorkers.compactMap({ $0 as? T }).first { return worker diff --git a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift index b7b0f206eef..f15d876cd40 100644 --- a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift +++ b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift @@ -69,6 +69,8 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt /// A Boolean value that returns whether pagination is finished public private(set) var hasLoadedAllPreviousChannels: Bool = false + private var loadedChannelsCount = 0 + private var shouldSkipInitialRemoteUpdate = false /// A type-erased delegate. var multicastDelegate: MulticastDelegate = .init() { @@ -146,12 +148,19 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt startChannelListObserverIfNeeded() channelListLinker.start(with: client.eventNotificationCenter) client.syncRepository.startTrackingChannelListController(self) - updateChannelList { [weak self] error in - guard let completion else { return } - self?.callback { - completion(error) + + if shouldSkipInitialRemoteUpdate { + shouldSkipInitialRemoteUpdate = false + state = .remoteDataFetched + hasLoadedAllPreviousChannels = loadedChannelsCount == 0 + markChannelsAsDeliveredIfNeeded(channels: Array(channels)) + callback { + completion?(nil) } + return } + + updateChannelList(completion) } // MARK: - Actions @@ -176,10 +185,11 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt let limit = limit ?? query.pagination.pageSize var updatedQuery = query - updatedQuery.pagination = Pagination(pageSize: limit, offset: channels.count) + updatedQuery.pagination = Pagination(pageSize: limit, offset: loadedChannelsCount) worker.update(channelListQuery: updatedQuery) { result in switch result { case let .success(channels): + self.loadedChannelsCount += channels.count self.markChannelsAsDeliveredIfNeeded(channels: channels) self.hasLoadedAllPreviousChannels = channels.count < limit self.callback { completion?(nil) } @@ -189,6 +199,38 @@ 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( + channels: [ChatChannel], + completion: (@Sendable (Error?) -> Void)? = nil + ) { + let prefilledChannels = filter.map { runtimeFilter in + channels.filter(runtimeFilter) + } ?? channels + + worker.prefill(channels: prefilledChannels, for: query) { [weak self] result in + switch result { + case let .success(savedChannels): + self?.loadedChannelsCount = savedChannels.count + 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 + self?.callback { + completion?(nil) + } + case let .failure(error): + self?.callback { + completion?(error) + } + } + } + } + // MARK: - Internal func refreshLoadedChannels(completion: @escaping @Sendable (Result, Error>) -> Void) { @@ -208,6 +250,7 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt switch result { case let .success(channels): self?.state = .remoteDataFetched + self?.loadedChannelsCount = channels.count self?.hasLoadedAllPreviousChannels = channels.count < limit // Mark channels as delivered if synchronization was successful diff --git a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift index 6153c33b003..40417dccbdf 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift @@ -348,6 +348,10 @@ extension NSManagedObjectContext { dto.reads.formUnion(reads) try payload.messages.forEach { _ = try saveMessage(payload: $0, channelDTO: dto, syncOwnReactions: true, cache: cache) } + if payload.channel.messageCount == nil, + let inferredMessageCount = inferredMessageCount(for: payload, existingMessageCount: dto.messageCount?.intValue) { + dto.messageCount = NSNumber(value: inferredMessageCount) + } var pendingMessages = Set() try payload.pendingMessages?.forEach { @@ -431,6 +435,16 @@ extension NSManagedObjectContext { return dto } + private func inferredMessageCount(for payload: ChannelPayload, existingMessageCount: Int?) -> Int? { + let minimumMessageCountFromPayload = max( + payload.messages.count, + payload.channel.lastMessageAt == nil ? 0 : 1 + ) + let inferredMessageCount = max(existingMessageCount ?? 0, minimumMessageCountFromPayload) + guard inferredMessageCount > 0 || existingMessageCount != nil else { return nil } + return inferredMessageCount + } + func channel(cid: ChannelId) -> ChannelDTO? { ChannelDTO.load(cid: cid, context: self) } diff --git a/Sources/StreamChat/Database/DatabaseSession.swift b/Sources/StreamChat/Database/DatabaseSession.swift index da49deeb19a..25cb5cbebf5 100644 --- a/Sources/StreamChat/Database/DatabaseSession.swift +++ b/Sources/StreamChat/Database/DatabaseSession.swift @@ -862,6 +862,9 @@ extension DatabaseSession { if let messageCount = payload.channelMessageCount { channelDTO.messageCount = NSNumber(value: messageCount) + } else if isNewMessage, !messageExistsLocally { + let currentMessageCount = channelDTO.messageCount?.intValue ?? 0 + channelDTO.messageCount = NSNumber(value: currentMessageCount + 1) } } diff --git a/Sources/StreamChat/Workers/ChannelListLinker.swift b/Sources/StreamChat/Workers/ChannelListLinker.swift index ae98d9959c3..a2e815a0f34 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: Sendable { private let clientConfig: ChatClientConfig private let databaseContainer: DatabaseContainer @@ -46,12 +45,20 @@ final class ChannelListLinker: Sendable { 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 28dd407556a..afab5cf2bfb 100644 --- a/Sources/StreamChat/Workers/ChannelListUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelListUpdater.swift @@ -41,6 +41,31 @@ class ChannelListUpdater: Worker, @unchecked Sendable { } } + func prefill( + channels: [ChatChannel], + for query: ChannelListQuery, + completion: (@Sendable (Result<[ChatChannel], Error>) -> Void)? = nil + ) { + nonisolated(unsafe) var savedChannels: [ChatChannel] = [] + database.write { session in + let queryDTO = session.saveQuery(query: query) + queryDTO.channels.removeAll() + + savedChannels = channels.compactMapLoggingError { channel in + let payload = channel.asPrefillPayload() + let channelDTO = try session.saveChannel(payload: payload, query: nil, cache: 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 @Sendable (Result, Error>) -> Void) { guard channelCount > 0 else { completion(.success(Set())) @@ -238,3 +263,340 @@ private extension ChannelListQuery { return query } } + +private extension ChatChannel { + func asPrefillPayload() -> ChannelPayload { + ChannelPayload( + channel: ChannelDetailPayload( + cid: cid, + name: name, + imageURL: imageURL, + extraData: extraData, + typeRawValue: cid.type.rawValue, + lastMessageAt: lastMessageAt, + createdAt: createdAt, + deletedAt: deletedAt, + updatedAt: updatedAt, + truncatedAt: truncatedAt, + createdBy: createdBy?.asPayload(), + config: config, + filterTags: Array(filterTags), + ownCapabilities: ownCapabilities.map(\.rawValue), + isDisabled: isDisabled, + isFrozen: isFrozen, + isBlocked: isBlocked, + isHidden: isHidden, + members: nil, + memberCount: memberCount, + messageCount: messageCount, + team: team, + cooldownDuration: cooldownDuration + ), + watcherCount: watcherCount, + watchers: lastActiveWatchers.map { $0.asPayload() }, + members: lastActiveMembers.map { $0.asPayload() }, + membership: membership?.asPayload(), + messages: latestMessages.map { $0.asPayload() }, + pendingMessages: pendingMessages.map { $0.asPayload() }, + pinnedMessages: pinnedMessages.map { $0.asPayload() }, + channelReads: reads.map { $0.asPayload() }, + isHidden: isHidden, + draft: draftMessage?.asPayload(), + activeLiveLocations: activeLiveLocations.map { $0.asPayload() }, + pushPreference: pushPreference?.asPayload() + ) + } +} + +private extension ChatUser { + func asPayload() -> UserPayload { + UserPayload( + id: id, + name: name, + imageURL: imageURL, + role: userRole, + teamsRole: teamsRole, + createdAt: userCreatedAt, + updatedAt: userUpdatedAt, + deactivatedAt: userDeactivatedAt, + lastActiveAt: lastActiveAt, + isOnline: isOnline, + isInvisible: false, + isBanned: isBanned, + teams: Array(teams), + language: language?.languageCode, + avgResponseTime: avgResponseTime, + extraData: extraData + ) + } +} + +private extension ChatChannelMember { + func asPayload() -> MemberPayload { + MemberPayload( + user: asUserPayload(), + userId: id, + role: memberRole, + createdAt: memberCreatedAt, + updatedAt: memberUpdatedAt, + banExpiresAt: banExpiresAt, + isBanned: isBannedFromChannel, + isShadowBanned: isShadowBannedFromChannel, + isInvited: isInvited, + inviteAcceptedAt: inviteAcceptedAt, + inviteRejectedAt: inviteRejectedAt, + archivedAt: archivedAt, + pinnedAt: pinnedAt, + notificationsMuted: notificationsMuted, + extraData: memberExtraData + ) + } + + private func asUserPayload() -> UserPayload { + UserPayload( + id: id, + name: name, + imageURL: imageURL, + role: userRole, + teamsRole: teamsRole, + createdAt: userCreatedAt, + updatedAt: userUpdatedAt, + deactivatedAt: userDeactivatedAt, + lastActiveAt: lastActiveAt, + isOnline: isOnline, + isInvisible: false, + isBanned: isBanned, + teams: Array(teams), + language: language?.languageCode, + avgResponseTime: avgResponseTime, + extraData: extraData + ) + } +} + +private extension ChatChannelRead { + func asPayload() -> ChannelReadPayload { + ChannelReadPayload( + user: user.asPayload(), + lastReadAt: lastReadAt, + lastReadMessageId: lastReadMessageId, + unreadMessagesCount: unreadMessagesCount, + lastDeliveredAt: lastDeliveredAt, + lastDeliveredMessageId: lastDeliveredMessageId + ) + } +} + +private extension ChatMessage { + func asPayload(depth: Int = 0) -> MessagePayload { + MessagePayload( + id: id, + cid: cid, + type: type, + user: author.asPayload(), + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + text: text, + command: command, + args: arguments, + parentId: parentMessageId, + showReplyInChannel: showReplyInChannel, + quotedMessageId: quotedMessage?.id, + quotedMessage: depth < 1 ? quotedMessage?.asPayload(depth: depth + 1) : nil, + mentionedUsers: mentionedUsers.map { $0.asPayload() }, + threadParticipants: threadParticipants.map { $0.asPayload() }, + replyCount: replyCount, + extraData: extraData, + latestReactions: latestReactions.map { $0.asPayload(messageId: id) }, + ownReactions: currentUserReactions.map { $0.asPayload(messageId: id) }, + reactionScores: reactionScores, + reactionCounts: reactionCounts, + reactionGroups: reactionGroups.mapValues { $0.asPayload() }, + isSilent: isSilent, + isShadowed: isShadowed, + attachments: allAttachments.compactMap { $0.asPayload() }, + channel: nil, + pinned: isPinned, + pinnedBy: pinDetails?.pinnedBy.asPayload(), + pinnedAt: pinDetails?.pinnedAt, + pinExpires: pinDetails?.expiresAt, + translations: translations, + originalLanguage: originalLanguage?.languageCode, + moderation: moderationDetails?.asPayload(), + moderationDetails: moderationDetails?.asPayload(), + messageTextUpdatedAt: textUpdatedAt, + poll: poll?.asPayload(), + draft: draftReply?.asPayload(), + reminder: reminder?.asPayload(cid: cid, messageId: id), + location: sharedLocation?.asPayload(), + member: MemberInfoPayload(channelRole: channelRole), + deletedForMe: deletedForMe + ) + } +} + +private extension ChatMessageReaction { + func asPayload(messageId: MessageId) -> MessageReactionPayload { + MessageReactionPayload( + type: type, + score: score, + messageId: messageId, + createdAt: createdAt, + updatedAt: updatedAt, + user: author.asPayload(), + extraData: extraData + ) + } +} + +private extension ChatMessageReactionGroup { + func asPayload() -> MessageReactionGroupPayload { + MessageReactionGroupPayload( + sumScores: sumScores, + count: count, + firstReactionAt: firstReactionAt, + lastReactionAt: lastReactionAt + ) + } +} + +private extension AnyChatMessageAttachment { + func asPayload() -> MessageAttachmentPayload? { + guard let rawPayload = try? JSONDecoder.stream.decode(RawJSON.self, from: payload) else { + return nil + } + + return MessageAttachmentPayload(type: type, payload: rawPayload) + } +} + +private extension DraftMessage { + func asPayload(depth: Int = 0) -> DraftPayload { + DraftPayload( + cid: cid, + channelPayload: nil, + createdAt: createdAt, + message: DraftMessagePayload( + id: id, + text: text, + command: command, + args: arguments, + showReplyInChannel: showReplyInChannel, + mentionedUsers: mentionedUsers.map { $0.asPayload() }, + extraData: extraData, + attachments: attachments.compactMap { $0.asPayload() }, + isSilent: isSilent + ), + quotedMessage: depth < 1 ? quotedMessage?.asPayload(depth: depth + 1) : nil, + parentId: threadId, + parentMessage: nil + ) + } +} + +private extension MessageReminderInfo { + func asPayload(cid: ChannelId?, messageId: MessageId) -> ReminderPayload? { + guard let cid else { return nil } + return ReminderPayload( + channelCid: cid, + messageId: messageId, + remindAt: remindAt, + createdAt: createdAt, + updatedAt: updatedAt + ) + } +} + +private extension SharedLocation { + func asPayload() -> SharedLocationPayload { + SharedLocationPayload( + channelId: channelId.rawValue, + messageId: messageId, + userId: userId, + latitude: latitude, + longitude: longitude, + createdAt: createdAt, + updatedAt: updatedAt, + endAt: endAt, + createdByDeviceId: createdByDeviceId + ) + } +} + +private extension PushPreference { + func asPayload() -> PushPreferencePayload { + PushPreferencePayload( + chatLevel: level.rawValue, + disabledUntil: disabledUntil + ) + } +} + +private extension MessageModerationDetails { + func asPayload() -> MessageModerationDetailsPayload { + MessageModerationDetailsPayload( + originalText: originalText, + action: action.rawValue, + textHarms: textHarms, + imageHarms: imageHarms, + blocklistMatched: blocklistMatched, + semanticFilterMatched: semanticFilterMatched, + platformCircumvented: platformCircumvented + ) + } +} + +private extension Poll { + func asPayload() -> PollPayload { + PollPayload( + allowAnswers: allowAnswers, + allowUserSuggestedOptions: allowUserSuggestedOptions, + answersCount: answersCount, + createdAt: createdAt, + createdById: createdBy?.id ?? "", + description: pollDescription ?? "", + enforceUniqueVote: enforceUniqueVote, + id: id, + name: name, + updatedAt: updatedAt ?? createdAt, + voteCount: voteCount, + latestAnswers: latestAnswers.map { Optional($0.asPayload()) }, + options: options.map { Optional($0.asPayload()) }, + ownVotes: ownVotes.map { Optional($0.asPayload()) }, + custom: extraData, + latestVotesByOption: Dictionary( + uniqueKeysWithValues: options.map { option in + (option.id, option.latestVotes.map { $0.asPayload() }) + } + ), + voteCountsByOption: voteCountsByOption ?? [:], + isClosed: isClosed, + maxVotesAllowed: maxVotesAllowed, + votingVisibility: votingVisibility?.rawValue, + createdBy: createdBy?.asPayload() + ) + } +} + +private extension PollOption { + func asPayload() -> PollOptionPayload { + PollOptionPayload(id: id, text: text, custom: extraData) + } +} + +private extension PollVote { + func asPayload() -> PollVotePayload { + PollVotePayload( + createdAt: createdAt, + id: id, + optionId: optionId, + pollId: pollId, + updatedAt: updatedAt, + answerText: answerText, + isAnswer: isAnswer, + userId: user?.id, + user: user?.asPayload() + ) + } +} diff --git a/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift b/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift index 932fa0b9835..6ef50a919e2 100644 --- a/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift +++ b/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift @@ -13,10 +13,14 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable @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 markAllRead_completion: ((Error?) -> Void)? @@ -35,9 +39,13 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable update_completion = nil update_completion_result = nil + prefill_queries.removeAll() + prefill_channels.removeAll() + fetch_queries.removeAll() fetch_completion = nil + refreshLoadedChannels_channelCounts.removeAll() markAllRead_completion = nil startWatchingChannels_cids.removeAll() @@ -54,6 +62,16 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable update_completion_result?.invoke(with: completion) } + override func prefill( + channels: [ChatChannel], + for query: ChannelListQuery, + completion: ((Result<[ChatChannel], Error>) -> Void)? = nil + ) { + _prefill_queries.mutate { $0.append(query) } + _prefill_channels.mutate { $0.append(channels) } + super.prefill(channels: channels, for: query, completion: completion) + } + override func markAllRead(completion: ((Error?) -> Void)? = nil) { markAllRead_completion = completion } @@ -72,6 +90,7 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable completion: @escaping (Result, any Error>) -> Void ) { record() + _refreshLoadedChannels_channelCounts.mutate { $0.append(channelCount) } refreshLoadedChannelsResult?.invoke(with: completion) } 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/ChatClient_Tests.swift b/Tests/StreamChatTests/ChatClient_Tests.swift index e1fcd45dfd0..9e1af8fe3c9 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -262,6 +262,65 @@ final class ChatClient_Tests: XCTestCase { XCTAssert(testEnv.apiClient?.init_requestEncoder is RequestEncoder_Spy) } + func test_groupedQueryChannels_callsAPIClientAndReturnsGroupedChannels() { + let client = ChatClient.mock(config: inMemoryStorageConfig) + let allCid = ChannelId.unique + let newCid = ChannelId.unique + let currentCid = ChannelId.unique + let expiredCid = ChannelId.unique + + let request = GroupedQueryChannelsRequestBody(limit: 4, watch: true, presence: false) + let expectedEndpoint: Endpoint = .groupedChannels(request: request) + let payload = GroupedQueryChannelsPayload( + all: .init( + channels: [dummyPayload(with: allCid)], + unreadCount: 1, + unreadChannels: 1 + ), + new: .init( + channels: [dummyPayload(with: newCid)], + unreadCount: 2, + unreadChannels: 1 + ), + current: .init( + channels: [dummyPayload(with: currentCid)], + unreadCount: 3, + unreadChannels: 1 + ), + expired: .init( + channels: [dummyPayload(with: expiredCid)], + unreadCount: 4, + unreadChannels: 1 + ), + duration: "12ms" + ) + + let expectation = self.expectation(description: "grouped query channels completes") + var receivedChannels: [[ChatChannel]]? + var receivedError: Error? + + client.groupedQueryChannels(limit: 4, watch: true, presence: false) { result in + switch result { + case let .success(channels): + receivedChannels = channels + 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( + receivedChannels?.map { $0.map(\.cid) }, + [[allCid], [newCid], [currentCid], [expiredCid]] + ) + } + 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 d12b2280edd..53c83c03a9c 100644 --- a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift @@ -224,6 +224,171 @@ 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(channels: prefilledChannels) { 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(channels: prefilledChannels) { 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(channels: prefilledChannels) { 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(channels: prefilledChannels) { 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_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(channels: [makePrefilledChannel(cid: replacementCid)]) { 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 { @@ -581,6 +746,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: @Sendable (ChatChannel) -> Bool = { channel in channel.memberCount == 4 @@ -1999,6 +2220,15 @@ final class ChannelListController_Tests: XCTestCase { ) } + private func makePrefilledChannel(cid: ChannelId) -> ChatChannel { + .mock( + cid: cid, + lastActiveMembers: [.mock(id: memberId)], + membership: .mock(id: memberId), + memberCount: 1 + ) + } + private func setupControllerWithFilter(_ filter: @escaping @Sendable (ChatChannel) -> Bool) { // Prepare controller controller = ChatChannelListController( diff --git a/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift b/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift index 8db9ecece6d..791528649e5 100644 --- a/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift +++ b/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift @@ -502,6 +502,130 @@ final class DatabaseSession_Tests: XCTestCase { XCTAssertNotNil(messageAfterEvent) } + func test_saveEvent_whenMessageDeletedEventHasPreviewMessage_updatesChannelPreview() throws { + // GIVEN + let previousMessage: MessagePayload = .dummy( + messageId: .unique, + authorUserId: .unique + ) + + let previewMessage: MessagePayload = .dummy( + messageId: .unique, + authorUserId: .unique, + createdAt: previousMessage.createdAt.addingTimeInterval(10) + ) + + let channel: ChannelPayload = .dummy( + messages: [ + previousMessage, + previewMessage + ] + ) + + try database.writeSynchronously { session in + try session.saveChannel(payload: channel) + } + + // WHEN + let messageDeletedEvent = EventPayload( + eventType: .messageDeleted, + cid: channel.channel.cid, + message: previewMessage + ) + + try database.writeSynchronously { session in + try session.saveEvent(payload: messageDeletedEvent) + } + + // THEN + let channelDTO = try XCTUnwrap(database.viewContext.channel(cid: channel.channel.cid)) + XCTAssertEqual(channelDTO.previewMessage?.id, previewMessage.id) + } + + func test_saveChannel_whenMessageCountIsMissing_infersItFromPayloadMessages() throws { + let firstMessage: MessagePayload = .dummy( + messageId: .unique, + authorUserId: .unique + ) + let secondMessage: MessagePayload = .dummy( + messageId: .unique, + authorUserId: .unique, + createdAt: firstMessage.createdAt.addingTimeInterval(10) + ) + + let channel: ChannelPayload = .dummy( + channel: .dummy( + cid: .unique, + lastMessageAt: secondMessage.createdAt, + messageCount: nil + ), + messages: [firstMessage, secondMessage] + ) + + try database.writeSynchronously { session in + try session.saveChannel(payload: channel) + } + + let channelDTO = try XCTUnwrap(database.viewContext.channel(cid: channel.channel.cid)) + XCTAssertEqual(channelDTO.messageCount?.intValue, 2) + } + + func test_saveChannel_whenMessageCountIsMissingAndLastMessageExists_infersAtLeastOneMessage() throws { + let lastMessageAt = Date() + let channel: ChannelPayload = .dummy( + channel: .dummy( + cid: .unique, + lastMessageAt: lastMessageAt, + messageCount: nil + ), + messages: [] + ) + + try database.writeSynchronously { session in + try session.saveChannel(payload: channel) + } + + let channelDTO = try XCTUnwrap(database.viewContext.channel(cid: channel.channel.cid)) + XCTAssertEqual(channelDTO.messageCount?.intValue, 1) + } + + func test_saveEvent_whenMessageNewEventComes_updatesChannelPreview() throws { + // GIVEN + let previewMessage: MessagePayload = .dummy( + messageId: .unique, + authorUserId: .unique + ) + + let channel: ChannelPayload = .dummy( + messages: [previewMessage] + ) + + try database.writeSynchronously { session in + try session.saveChannel(payload: channel) + } + + // WHEN + let newMessage: MessagePayload = .dummy( + messageId: .unique, + authorUserId: .unique, + createdAt: previewMessage.createdAt.addingTimeInterval(10) + ) + + let messageNewEvent = EventPayload( + eventType: .messageNew, + cid: channel.channel.cid, + message: newMessage + ) + + try database.writeSynchronously { session in + try session.saveEvent(payload: messageNewEvent) + } + + // THEN + let channelDTO = try XCTUnwrap(database.viewContext.channel(cid: channel.channel.cid)) + XCTAssertEqual(channelDTO.previewMessage?.id, newMessage.id) + } + func test_saveEvent_whenMessageNewEventComes_whenIsThreadReply_thenShowInsideThreadIsTrue() throws { // GIVEN let channel: ChannelPayload = .dummy( @@ -654,6 +778,76 @@ final class DatabaseSession_Tests: XCTestCase { XCTAssertEqual(channelModel.latestMessages.first?.id, newMessage.id) } + func test_saveEvent_whenMessageNewEventComesWithoutChannelMessageCount_incrementsChannelMessageCount() 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, 2) + } + + func test_saveEvent_whenNotificationMessageNewEventComesWithoutChannelMessageCount_incrementsChannelMessageCount() 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, 2) + } + func test_saveEvent_whenMessageDeletedEvent_latestMessagesFirstStillReturnsDeletedMessage() throws { // GIVEN let message: MessagePayload = .dummy( From e1c4005e02ffe0dcab5d1ac5e06ddc0c60576663 Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Tue, 14 Apr 2026 13:18:20 +0200 Subject: [PATCH 02/31] Updated grouped endpoint --- CHANGELOG.md | 2 +- .../Payloads/ChannelListPayload.swift | 21 ++--- Sources/StreamChat/ChatClient.swift | 88 +++++++++++++++---- .../Payloads/ChannelListPayload_Tests.swift | 66 ++++++++++++++ Tests/StreamChatTests/ChatClient_Tests.swift | 61 +++++++------ 5 files changed, 175 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28a33a2b381..6ba37d4706f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### ✅ Added - Add `ChatChannelListController.prefill(channels:completion:)` for priming controller-local channel data before the first synchronize call while preserving normal pagination, observation, and offline refresh behavior -- Add `ChatClient.groupedQueryChannels(limit:watch:presence:)` to fetch grouped channel buckets as `[[ChatChannel]]` in `all`, `new`, `current`, `expired` order +- Add `ChatClient.groupedQueryChannels(limit:watch:presence:)` to fetch grouped channel families as `GroupedChannels`, preserving backend bucket keys and response order ### 🔄 Changed diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift index db1a5f5828c..846b36ff98a 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift @@ -32,19 +32,15 @@ struct GroupedQueryChannelsRequestBody: Encodable { } struct GroupedQueryChannelsPayload { - let all: GroupedQueryChannelsBucketPayload - let new: GroupedQueryChannelsBucketPayload - let current: GroupedQueryChannelsBucketPayload - let expired: GroupedQueryChannelsBucketPayload + let family: String + let buckets: [GroupedQueryChannelsBucketPayload] let duration: String } extension GroupedQueryChannelsPayload: Decodable { enum CodingKeys: String, CodingKey { - case all - case new - case current - case expired + case family + case buckets case duration } @@ -52,16 +48,15 @@ extension GroupedQueryChannelsPayload: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) self.init( - all: try container.decode(GroupedQueryChannelsBucketPayload.self, forKey: .all), - new: try container.decode(GroupedQueryChannelsBucketPayload.self, forKey: .new), - current: try container.decode(GroupedQueryChannelsBucketPayload.self, forKey: .current), - expired: try container.decode(GroupedQueryChannelsBucketPayload.self, forKey: .expired), + family: try container.decode(String.self, forKey: .family), + buckets: try container.decodeArrayIgnoringFailures([GroupedQueryChannelsBucketPayload].self, forKey: .buckets), duration: try container.decode(String.self, forKey: .duration) ) } } struct GroupedQueryChannelsBucketPayload { + let key: String let channels: [ChannelPayload] let unreadCount: Int let unreadChannels: Int @@ -69,6 +64,7 @@ struct GroupedQueryChannelsBucketPayload { extension GroupedQueryChannelsBucketPayload: Decodable { enum CodingKeys: String, CodingKey { + case key case channels case unreadCount = "unread_count" case unreadChannels = "unread_channels" @@ -78,6 +74,7 @@ extension GroupedQueryChannelsBucketPayload: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) self.init( + key: try container.decode(String.self, forKey: .key), channels: try container.decodeArrayIgnoringFailures([ChannelPayload].self, forKey: .channels), unreadCount: try container.decode(Int.self, forKey: .unreadCount), unreadChannels: try container.decode(Int.self, forKey: .unreadChannels) diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index b81092ac1bb..86d58c20a89 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -500,15 +500,15 @@ public class ChatClient: @unchecked Sendable { authenticationRepository.setToken(token: token, completeTokenWaiters: true) } - /// Loads grouped channel buckets and returns them in the following order: - /// `all`, `new`, `current`, `expired`. + /// Loads grouped channel buckets for the app's configured family. /// - /// The response is converted to `ChatChannel` models without persisting the data locally. + /// The response preserves the backend-provided family and bucket keys and is converted to `ChatChannel` + /// models without persisting the data locally. public func groupedQueryChannels( limit: Int? = nil, watch: Bool = false, presence: Bool = false, - completion: @escaping (Result<[[ChatChannel]], Error>) -> Void + completion: @escaping (Result) -> Void ) { let request = GroupedQueryChannelsRequestBody( limit: limit, @@ -529,15 +529,15 @@ public class ChatClient: @unchecked Sendable { } } - /// Loads grouped channel buckets and returns them in the following order: - /// `all`, `new`, `current`, `expired`. + /// Loads grouped channel buckets for the app's configured family. /// - /// The response is converted to `ChatChannel` models without persisting the data locally. + /// The response preserves the backend-provided family and bucket keys and is converted to `ChatChannel` + /// models without persisting the data locally. public func groupedQueryChannels( limit: Int? = nil, watch: Bool = false, presence: Bool = false - ) async throws -> [[ChatChannel]] { + ) async throws -> GroupedChannels { try await withCheckedThrowingContinuation { continuation in groupedQueryChannels(limit: limit, watch: watch, presence: presence) { result in continuation.resume(with: result) @@ -849,23 +849,26 @@ extension ChatClient { private static func groupedChannels( from payload: GroupedQueryChannelsPayload, session: DatabaseSession - ) throws -> [[ChatChannel]] { - let buckets = [ - payload.all.channels, - payload.new.channels, - payload.current.channels, - payload.expired.channels - ] - - let models = try buckets.map { channels in - try channels.map { channelPayload in + ) throws -> GroupedChannels { + let buckets = try payload.buckets.map { bucketPayload in + let channels = try bucketPayload.channels.map { channelPayload in let dto = try session.saveChannel(payload: channelPayload) return try dto.asModel() } + + return GroupedChannelsBucket( + key: bucketPayload.key, + channels: channels, + unreadCount: bucketPayload.unreadCount, + unreadChannels: bucketPayload.unreadChannels + ) } (session as? NSManagedObjectContext)?.rollback() - return models + return GroupedChannels( + family: payload.family, + buckets: buckets + ) } func backgroundWorker(of type: T.Type) throws -> T { @@ -882,6 +885,53 @@ extension ChatClient { } } +/// A grouped channels response returned by `ChatClient.groupedQueryChannels`. +public struct GroupedChannels: Equatable { + /// The grouped channel family configured for the current app. + public let family: String + + /// The grouped channel buckets returned by the backend in response order. + public let buckets: [GroupedChannelsBucket] + + /// Convenience access to the grouped channels without bucket metadata. + public var channels: [[ChatChannel]] { buckets.map(\.channels) } + + public init( + family: String, + buckets: [GroupedChannelsBucket] + ) { + self.family = family + self.buckets = buckets + } +} + +/// A grouped channels bucket returned by `ChatClient.groupedQueryChannels`. +public struct GroupedChannelsBucket: Equatable { + /// The backend-defined key for this bucket within the family. + public let key: String + + /// The channels that belong to this bucket. + public let channels: [ChatChannel] + + /// The total unread message count across the bucket. + public let unreadCount: Int + + /// The total unread channel count in the bucket. + public let unreadChannels: Int + + public init( + key: String, + channels: [ChatChannel], + unreadCount: Int, + unreadChannels: Int + ) { + self.key = key + self.channels = channels + self.unreadCount = unreadCount + self.unreadChannels = unreadChannels + } +} + extension ClientError { public final class MissingLocalStorageURL: ClientError, @unchecked Sendable { override public var localizedDescription: String { "The URL provided in ChatClientConfig is `nil`." } diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift index b1522c11a00..69b837d565f 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift @@ -62,6 +62,72 @@ final class ChannelListPayload_Tests: XCTestCase { XCTAssertEqual(payload.channels.count, 2) } + func test_groupedQueryChannelsPayload_decodesDynamicBuckets() throws { + let channelId = ChannelId(type: .messaging, id: "bucket-channel") + let json = """ + { + "family": "support", + "buckets": [ + { + "key": "all-open", + "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_count": 3, + "unread_channels": 1 + } + ], + "duration": "12ms" + } + """.data(using: .utf8)! + + let payload = try JSONDecoder.default.decode(GroupedQueryChannelsPayload.self, from: json) + + XCTAssertEqual(payload.family, "support") + XCTAssertEqual(payload.buckets.map(\.key), ["all-open"]) + XCTAssertEqual(payload.buckets.first?.channels.map(\.channel.cid), [channelId]) + XCTAssertEqual(payload.buckets.first?.unreadCount, 3) + XCTAssertEqual(payload.buckets.first?.unreadChannels, 1) + XCTAssertEqual(payload.duration, "12ms") + } + 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 9e1af8fe3c9..2e9c474eac2 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -264,45 +264,45 @@ final class ChatClient_Tests: XCTestCase { func test_groupedQueryChannels_callsAPIClientAndReturnsGroupedChannels() { let client = ChatClient.mock(config: inMemoryStorageConfig) - let allCid = ChannelId.unique - let newCid = ChannelId.unique - let currentCid = ChannelId.unique - let expiredCid = ChannelId.unique + 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( - all: .init( - channels: [dummyPayload(with: allCid)], - unreadCount: 1, - unreadChannels: 1 - ), - new: .init( - channels: [dummyPayload(with: newCid)], - unreadCount: 2, - unreadChannels: 1 - ), - current: .init( - channels: [dummyPayload(with: currentCid)], - unreadCount: 3, - unreadChannels: 1 - ), - expired: .init( - channels: [dummyPayload(with: expiredCid)], - unreadCount: 4, - unreadChannels: 1 - ), + family: "support", + buckets: [ + .init( + key: "all-open", + channels: [dummyPayload(with: firstCid)], + unreadCount: 1, + unreadChannels: 1 + ), + .init( + key: "assigned", + channels: [dummyPayload(with: secondCid)], + unreadCount: 2, + unreadChannels: 1 + ), + .init( + key: "escalated", + channels: [dummyPayload(with: thirdCid)], + unreadCount: 4, + unreadChannels: 2 + ) + ], duration: "12ms" ) let expectation = self.expectation(description: "grouped query channels completes") - var receivedChannels: [[ChatChannel]]? + var receivedGroupedChannels: GroupedChannels? var receivedError: Error? client.groupedQueryChannels(limit: 4, watch: true, presence: false) { result in switch result { - case let .success(channels): - receivedChannels = channels + case let .success(groupedChannels): + receivedGroupedChannels = groupedChannels case let .failure(error): receivedError = error } @@ -315,10 +315,9 @@ final class ChatClient_Tests: XCTestCase { waitForExpectations(timeout: defaultTimeout) XCTAssertNil(receivedError) - XCTAssertEqual( - receivedChannels?.map { $0.map(\.cid) }, - [[allCid], [newCid], [currentCid], [expiredCid]] - ) + XCTAssertEqual(receivedGroupedChannels?.family, "support") + XCTAssertEqual(receivedGroupedChannels?.buckets.map(\.key), ["all-open", "assigned", "escalated"]) + XCTAssertEqual(receivedGroupedChannels?.channels.map { $0.map(\.cid) }, [[firstCid], [secondCid], [thirdCid]]) } func test_disconnect_flushesRequestsQueue() throws { From 17eb9574df6812a49afbe10610ea7fd162183efe Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Wed, 15 Apr 2026 15:37:38 +0200 Subject: [PATCH 03/31] Updated payload --- CHANGELOG.md | 2 +- .../Payloads/ChannelListPayload.swift | 16 ++---- Sources/StreamChat/ChatClient.swift | 54 ++++++++----------- .../Payloads/ChannelListPayload_Tests.swift | 19 +++---- Tests/StreamChatTests/ChatClient_Tests.swift | 19 +++---- 5 files changed, 43 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ba37d4706f..798e4fa7bc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### ✅ Added - Add `ChatChannelListController.prefill(channels:completion:)` for priming controller-local channel data before the first synchronize call while preserving normal pagination, observation, and offline refresh behavior -- Add `ChatClient.groupedQueryChannels(limit:watch:presence:)` to fetch grouped channel families as `GroupedChannels`, preserving backend bucket keys and response order +- Add `ChatClient.groupedQueryChannels(limit:watch:presence:)` to fetch grouped channel groups as `GroupedChannels`, preserving backend group keys ### 🔄 Changed diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift index 846b36ff98a..ac3e91b6599 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift @@ -32,15 +32,13 @@ struct GroupedQueryChannelsRequestBody: Encodable { } struct GroupedQueryChannelsPayload { - let family: String - let buckets: [GroupedQueryChannelsBucketPayload] + let groups: [String: GroupedQueryChannelsGroupPayload] let duration: String } extension GroupedQueryChannelsPayload: Decodable { enum CodingKeys: String, CodingKey { - case family - case buckets + case groups case duration } @@ -48,23 +46,20 @@ extension GroupedQueryChannelsPayload: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) self.init( - family: try container.decode(String.self, forKey: .family), - buckets: try container.decodeArrayIgnoringFailures([GroupedQueryChannelsBucketPayload].self, forKey: .buckets), + groups: try container.decode([String: GroupedQueryChannelsGroupPayload].self, forKey: .groups), duration: try container.decode(String.self, forKey: .duration) ) } } -struct GroupedQueryChannelsBucketPayload { - let key: String +struct GroupedQueryChannelsGroupPayload { let channels: [ChannelPayload] let unreadCount: Int let unreadChannels: Int } -extension GroupedQueryChannelsBucketPayload: Decodable { +extension GroupedQueryChannelsGroupPayload: Decodable { enum CodingKeys: String, CodingKey { - case key case channels case unreadCount = "unread_count" case unreadChannels = "unread_channels" @@ -74,7 +69,6 @@ extension GroupedQueryChannelsBucketPayload: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) self.init( - key: try container.decode(String.self, forKey: .key), channels: try container.decodeArrayIgnoringFailures([ChannelPayload].self, forKey: .channels), unreadCount: try container.decode(Int.self, forKey: .unreadCount), unreadChannels: try container.decode(Int.self, forKey: .unreadChannels) diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index 86d58c20a89..912fb8a0cac 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -500,9 +500,9 @@ public class ChatClient: @unchecked Sendable { authenticationRepository.setToken(token: token, completeTokenWaiters: true) } - /// Loads grouped channel buckets for the app's configured family. + /// Loads grouped channel groups for the app. /// - /// The response preserves the backend-provided family and bucket keys and is converted to `ChatChannel` + /// The response preserves the backend-provided group keys and is converted to `ChatChannel` /// models without persisting the data locally. public func groupedQueryChannels( limit: Int? = nil, @@ -529,9 +529,9 @@ public class ChatClient: @unchecked Sendable { } } - /// Loads grouped channel buckets for the app's configured family. + /// Loads grouped channel groups for the app. /// - /// The response preserves the backend-provided family and bucket keys and is converted to `ChatChannel` + /// The response preserves the backend-provided group keys and is converted to `ChatChannel` /// models without persisting the data locally. public func groupedQueryChannels( limit: Int? = nil, @@ -850,24 +850,22 @@ extension ChatClient { from payload: GroupedQueryChannelsPayload, session: DatabaseSession ) throws -> GroupedChannels { - let buckets = try payload.buckets.map { bucketPayload in - let channels = try bucketPayload.channels.map { channelPayload in + let groups = try payload.groups.mapValues { groupPayload in + let channels = try groupPayload.channels.map { channelPayload in let dto = try session.saveChannel(payload: channelPayload) return try dto.asModel() } - return GroupedChannelsBucket( - key: bucketPayload.key, + return GroupedChannelsGroup( channels: channels, - unreadCount: bucketPayload.unreadCount, - unreadChannels: bucketPayload.unreadChannels + unreadCount: groupPayload.unreadCount, + unreadChannels: groupPayload.unreadChannels ) } (session as? NSManagedObjectContext)?.rollback() return GroupedChannels( - family: payload.family, - buckets: buckets + groups: groups ) } @@ -887,45 +885,35 @@ extension ChatClient { /// A grouped channels response returned by `ChatClient.groupedQueryChannels`. public struct GroupedChannels: Equatable { - /// The grouped channel family configured for the current app. - public let family: String + /// The grouped channel groups returned by the backend, keyed by group name. + public let groups: [String: GroupedChannelsGroup] - /// The grouped channel buckets returned by the backend in response order. - public let buckets: [GroupedChannelsBucket] - - /// Convenience access to the grouped channels without bucket metadata. - public var channels: [[ChatChannel]] { buckets.map(\.channels) } + /// Convenience access to the grouped channels without per-group metadata. + public var channels: [String: [ChatChannel]] { groups.mapValues(\.channels) } public init( - family: String, - buckets: [GroupedChannelsBucket] + groups: [String: GroupedChannelsGroup] ) { - self.family = family - self.buckets = buckets + self.groups = groups } } -/// A grouped channels bucket returned by `ChatClient.groupedQueryChannels`. -public struct GroupedChannelsBucket: Equatable { - /// The backend-defined key for this bucket within the family. - public let key: String - - /// The channels that belong to this bucket. +/// A grouped channels group returned by `ChatClient.groupedQueryChannels`. +public struct GroupedChannelsGroup: Equatable { + /// The channels that belong to this group. public let channels: [ChatChannel] - /// The total unread message count across the bucket. + /// The total unread message count across the group. public let unreadCount: Int - /// The total unread channel count in the bucket. + /// The total unread channel count in the group. public let unreadChannels: Int public init( - key: String, channels: [ChatChannel], unreadCount: Int, unreadChannels: Int ) { - self.key = key self.channels = channels self.unreadCount = unreadCount self.unreadChannels = unreadChannels diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift index 69b837d565f..996ecf886ff 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift @@ -62,14 +62,12 @@ final class ChannelListPayload_Tests: XCTestCase { XCTAssertEqual(payload.channels.count, 2) } - func test_groupedQueryChannelsPayload_decodesDynamicBuckets() throws { + func test_groupedQueryChannelsPayload_decodesGroupsMap() throws { let channelId = ChannelId(type: .messaging, id: "bucket-channel") let json = """ { - "family": "support", - "buckets": [ - { - "key": "all-open", + "groups": { + "all": { "channels": [ { "channel": { @@ -113,18 +111,17 @@ final class ChannelListPayload_Tests: XCTestCase { "unread_count": 3, "unread_channels": 1 } - ], + }, "duration": "12ms" } """.data(using: .utf8)! let payload = try JSONDecoder.default.decode(GroupedQueryChannelsPayload.self, from: json) - XCTAssertEqual(payload.family, "support") - XCTAssertEqual(payload.buckets.map(\.key), ["all-open"]) - XCTAssertEqual(payload.buckets.first?.channels.map(\.channel.cid), [channelId]) - XCTAssertEqual(payload.buckets.first?.unreadCount, 3) - XCTAssertEqual(payload.buckets.first?.unreadChannels, 1) + XCTAssertEqual(payload.groups.keys.sorted(), ["all"]) + XCTAssertEqual(payload.groups["all"]?.channels.map(\.channel.cid), [channelId]) + XCTAssertEqual(payload.groups["all"]?.unreadCount, 3) + XCTAssertEqual(payload.groups["all"]?.unreadChannels, 1) XCTAssertEqual(payload.duration, "12ms") } diff --git a/Tests/StreamChatTests/ChatClient_Tests.swift b/Tests/StreamChatTests/ChatClient_Tests.swift index 2e9c474eac2..19d654f82ed 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -271,22 +271,18 @@ final class ChatClient_Tests: XCTestCase { let request = GroupedQueryChannelsRequestBody(limit: 4, watch: true, presence: false) let expectedEndpoint: Endpoint = .groupedChannels(request: request) let payload = GroupedQueryChannelsPayload( - family: "support", - buckets: [ - .init( - key: "all-open", + groups: [ + "all": .init( channels: [dummyPayload(with: firstCid)], unreadCount: 1, unreadChannels: 1 ), - .init( - key: "assigned", + "new": .init( channels: [dummyPayload(with: secondCid)], unreadCount: 2, unreadChannels: 1 ), - .init( - key: "escalated", + "current": .init( channels: [dummyPayload(with: thirdCid)], unreadCount: 4, unreadChannels: 2 @@ -315,9 +311,10 @@ final class ChatClient_Tests: XCTestCase { waitForExpectations(timeout: defaultTimeout) XCTAssertNil(receivedError) - XCTAssertEqual(receivedGroupedChannels?.family, "support") - XCTAssertEqual(receivedGroupedChannels?.buckets.map(\.key), ["all-open", "assigned", "escalated"]) - XCTAssertEqual(receivedGroupedChannels?.channels.map { $0.map(\.cid) }, [[firstCid], [secondCid], [thirdCid]]) + XCTAssertEqual(receivedGroupedChannels?.groups.keys.sorted(), ["all", "current", "new"]) + XCTAssertEqual(receivedGroupedChannels?.groups["all"]?.channels.map(\.cid), [firstCid]) + XCTAssertEqual(receivedGroupedChannels?.groups["new"]?.channels.map(\.cid), [secondCid]) + XCTAssertEqual(receivedGroupedChannels?.groups["current"]?.channels.map(\.cid), [thirdCid]) } func test_disconnect_flushesRequestsQueue() throws { From 6939d944a0eed87e1486290a9406b6dd268187f8 Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Fri, 17 Apr 2026 11:04:53 +0200 Subject: [PATCH 04/31] Small updates --- CHANGELOG.md | 2 +- Sources/StreamChat/ChatClient.swift | 8 +++++++- Tests/StreamChatTests/ChatClient_Tests.swift | 8 +++++--- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 798e4fa7bc2..7713d4d0b56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### ✅ Added - Add `ChatChannelListController.prefill(channels:completion:)` for priming controller-local channel data before the first synchronize call while preserving normal pagination, observation, and offline refresh behavior -- Add `ChatClient.groupedQueryChannels(limit:watch:presence:)` to fetch grouped channel groups as `GroupedChannels`, preserving backend group keys +- Add `ChatClient.groupedQueryChannels(limit:watch:presence:)` to fetch grouped channel groups as `GroupedChannels`, preserving backend group keys and exposing per-group channels and unread counts for integrators ### 🔄 Changed diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index 912fb8a0cac..3a57fdfbeb9 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -888,9 +888,15 @@ public struct GroupedChannels: Equatable { /// The grouped channel groups returned by the backend, keyed by group name. public let groups: [String: GroupedChannelsGroup] - /// Convenience access to the grouped channels without per-group metadata. + /// Convenience access to each group's channels, keyed by group name. public var channels: [String: [ChatChannel]] { groups.mapValues(\.channels) } + /// Convenience access to each group's unread message count, keyed by group name. + public var unreadCounts: [String: Int] { groups.mapValues(\.unreadCount) } + + /// Convenience access to each group's unread channel count, keyed by group name. + public var unreadChannels: [String: Int] { groups.mapValues(\.unreadChannels) } + public init( groups: [String: GroupedChannelsGroup] ) { diff --git a/Tests/StreamChatTests/ChatClient_Tests.swift b/Tests/StreamChatTests/ChatClient_Tests.swift index 19d654f82ed..210ae7ba202 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -312,9 +312,11 @@ final class ChatClient_Tests: XCTestCase { XCTAssertNil(receivedError) XCTAssertEqual(receivedGroupedChannels?.groups.keys.sorted(), ["all", "current", "new"]) - XCTAssertEqual(receivedGroupedChannels?.groups["all"]?.channels.map(\.cid), [firstCid]) - XCTAssertEqual(receivedGroupedChannels?.groups["new"]?.channels.map(\.cid), [secondCid]) - XCTAssertEqual(receivedGroupedChannels?.groups["current"]?.channels.map(\.cid), [thirdCid]) + XCTAssertEqual(receivedGroupedChannels?.channels["all"]?.map(\.cid), [firstCid]) + XCTAssertEqual(receivedGroupedChannels?.channels["new"]?.map(\.cid), [secondCid]) + XCTAssertEqual(receivedGroupedChannels?.channels["current"]?.map(\.cid), [thirdCid]) + XCTAssertEqual(receivedGroupedChannels?.unreadCounts, ["all": 1, "new": 2, "current": 4]) + XCTAssertEqual(receivedGroupedChannels?.unreadChannels, ["all": 1, "new": 1, "current": 2]) } func test_disconnect_flushesRequestsQueue() throws { From 652a0feb31f88be1f9ee47c98145cfc5612a4ce6 Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Fri, 17 Apr 2026 12:06:52 +0200 Subject: [PATCH 05/31] Unread count fixes --- CHANGELOG.md | 2 +- Sources/StreamChat/ChatClient.swift | 13 +++++++++-- Tests/StreamChatTests/ChatClient_Tests.swift | 24 ++++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7713d4d0b56..da02ae9c82a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### ✅ Added - Add `ChatChannelListController.prefill(channels:completion:)` for priming controller-local channel data before the first synchronize call while preserving normal pagination, observation, and offline refresh behavior -- Add `ChatClient.groupedQueryChannels(limit:watch:presence:)` to fetch grouped channel groups as `GroupedChannels`, preserving backend group keys and exposing per-group channels and unread counts for integrators +- Add `ChatClient.groupedQueryChannels(limit:watch:presence:)` to fetch grouped channel groups as `GroupedChannels`, preserving backend group keys and exposing normalized per-group channels and unread counts for integrators ### 🔄 Changed diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index 3a57fdfbeb9..382d76c9ed4 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -921,8 +921,17 @@ public struct GroupedChannelsGroup: Equatable { unreadChannels: Int ) { self.channels = channels - self.unreadCount = unreadCount - self.unreadChannels = unreadChannels + let derivedUnreadCount = channels.reduce(into: 0) { partialResult, channel in + partialResult += channel.unreadCount.messages + } + let derivedUnreadChannels = channels.reduce(into: 0) { partialResult, channel in + if channel.unreadCount.messages > 0 { + partialResult += 1 + } + } + + self.unreadCount = max(unreadCount, derivedUnreadCount) + self.unreadChannels = max(unreadChannels, derivedUnreadChannels) } } diff --git a/Tests/StreamChatTests/ChatClient_Tests.swift b/Tests/StreamChatTests/ChatClient_Tests.swift index 210ae7ba202..93be5ebe4aa 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -319,6 +319,30 @@ final class ChatClient_Tests: XCTestCase { XCTAssertEqual(receivedGroupedChannels?.unreadChannels, ["all": 1, "new": 1, "current": 2]) } + 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( + channels: [firstChannel, secondChannel, thirdChannel], + unreadCount: 0, + unreadChannels: 0 + ) + + XCTAssertEqual(group.unreadCount, 4) + XCTAssertEqual(group.unreadChannels, 2) + } + func test_disconnect_flushesRequestsQueue() throws { // Create a chat client let client = ChatClient( From 141fc1e081fcad18e33d91aaa84bbf3efa74990a Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Fri, 17 Apr 2026 12:17:51 +0200 Subject: [PATCH 06/31] Small Fixes --- Sources/StreamChat/ChatClient.swift | 52 ++++++++++++++++++-- Tests/StreamChatTests/ChatClient_Tests.swift | 4 +- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index 382d76c9ed4..e3d8593e939 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -517,11 +517,15 @@ public class ChatClient: @unchecked Sendable { ) let endpoint: Endpoint = .groupedChannels(request: request) - apiClient.request(endpoint: endpoint) { [databaseContainer] result in + apiClient.request(endpoint: endpoint) { [databaseContainer, currentUserId] result in switch result { case let .success(payload): databaseContainer.write(converting: { session in - try Self.groupedChannels(from: payload, session: session) + try Self.groupedChannels( + from: payload, + session: session, + currentUserId: currentUserId + ) }, completion: completion) case let .failure(error): completion(.failure(error)) @@ -848,12 +852,23 @@ extension ChatClient: ConnectionDetailsProviderDelegate { extension ChatClient { private static func groupedChannels( from payload: GroupedQueryChannelsPayload, - session: DatabaseSession + session: DatabaseSession, + currentUserId: UserId? ) throws -> GroupedChannels { let groups = try payload.groups.mapValues { groupPayload in let channels = try groupPayload.channels.map { channelPayload in - let dto = try session.saveChannel(payload: channelPayload) - return try dto.asModel() + _ = try session.saveChannel(payload: channelPayload) + + let unreadCount = groupedChannelUnreadCount( + from: channelPayload, + currentUserId: currentUserId + ) + + return channelPayload.asModel( + currentUserId: currentUserId, + currentlyTypingUsers: nil, + unreadCount: unreadCount + ) } return GroupedChannelsGroup( @@ -869,6 +884,33 @@ extension ChatClient { ) } + private static func groupedChannelUnreadCount( + from payload: ChannelPayload, + currentUserId: UserId? + ) -> ChannelUnreadCount? { + guard let currentUserId, + let currentUserRead = payload.channelReads.first(where: { $0.user.id == currentUserId }) + else { + return nil + } + + let unreadMessagesCount = currentUserRead.unreadMessagesCount + guard unreadMessagesCount > 0 else { return .noUnread } + + let unreadMentionsCount = payload.messages + .sorted { $0.createdAt > $1.createdAt } + .prefix(unreadMessagesCount) + .filter { messagePayload in + messagePayload.mentionedUsers.contains { $0.id == currentUserId } + } + .count + + return ChannelUnreadCount( + messages: unreadMessagesCount, + mentions: unreadMentionsCount + ) + } + func backgroundWorker(of type: T.Type) throws -> T { if let worker = backgroundWorkers.compactMap({ $0 as? T }).first { return worker diff --git a/Tests/StreamChatTests/ChatClient_Tests.swift b/Tests/StreamChatTests/ChatClient_Tests.swift index 93be5ebe4aa..6c8553e7ccd 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -264,6 +264,7 @@ final class ChatClient_Tests: XCTestCase { func test_groupedQueryChannels_callsAPIClientAndReturnsGroupedChannels() { let client = ChatClient.mock(config: inMemoryStorageConfig) + client.setToken(token: try! .init(rawValue: "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiZHVtbXlDdXJyZW50VXNlciJ9.signature")) let firstCid = ChannelId.unique let secondCid = ChannelId.unique let thirdCid = ChannelId.unique @@ -315,7 +316,8 @@ final class ChatClient_Tests: XCTestCase { XCTAssertEqual(receivedGroupedChannels?.channels["all"]?.map(\.cid), [firstCid]) XCTAssertEqual(receivedGroupedChannels?.channels["new"]?.map(\.cid), [secondCid]) XCTAssertEqual(receivedGroupedChannels?.channels["current"]?.map(\.cid), [thirdCid]) - XCTAssertEqual(receivedGroupedChannels?.unreadCounts, ["all": 1, "new": 2, "current": 4]) + XCTAssertEqual(receivedGroupedChannels?.channels["all"]?.first?.unreadCount.messages, 10) + XCTAssertEqual(receivedGroupedChannels?.unreadCounts, ["all": 10, "new": 10, "current": 10]) XCTAssertEqual(receivedGroupedChannels?.unreadChannels, ["all": 1, "new": 1, "current": 2]) } From e6e63ced81ff94d3f980abbee6b46376c1a9554f Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Fri, 17 Apr 2026 13:20:02 +0200 Subject: [PATCH 07/31] Added events payload --- CHANGELOG.md | 2 + .../Payloads/ChannelListPayload.swift | 4 +- Sources/StreamChat/ChatClient.swift | 52 ++------------ .../Database/DTOs/CurrentUserDTO.swift | 24 +++++++ .../StreamChat/Database/DatabaseSession.swift | 8 +++ .../StreamChatModel.xcdatamodel/contents | 3 +- Sources/StreamChat/Models/CurrentUser.swift | 5 ++ Sources/StreamChat/Models/UnreadCount.swift | 3 + .../Events/ChannelEvents.swift | 17 ++++- .../WebSocketClient/Events/Event.swift | 6 ++ .../WebSocketClient/Events/EventPayload.swift | 6 ++ .../Events/MessageEvents.swift | 12 +++- .../Events/NotificationEvents.swift | 68 ++++++++++++++++--- .../Database/DatabaseSession_Mock.swift | 11 ++- .../Payloads/ChannelListPayload_Tests.swift | 60 +++++++++++++++- Tests/StreamChatTests/ChatClient_Tests.swift | 4 +- .../Database/DTOs/CurrentUserDTO_Tests.swift | 16 +++++ .../Database/DatabaseSession_Tests.swift | 22 ++++++ .../Events/MessageEvents_Tests.swift | 30 ++++++++ .../Events/NotificationEvents_Tests.swift | 44 ++++++++++++ 20 files changed, 323 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da02ae9c82a..0b819f02472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### ✅ Added - Add `ChatChannelListController.prefill(channels:completion:)` for priming controller-local channel data before the first synchronize call while preserving normal pagination, observation, and offline refresh behavior - Add `ChatClient.groupedQueryChannels(limit:watch:presence:)` to fetch grouped channel groups as `GroupedChannels`, preserving backend group keys and exposing normalized per-group channels and unread counts for integrators +- Add optional `groupedUnreadChannels` data to grouped unread websocket events and persist it on `CurrentChatUser` for integrators ### 🔄 Changed +- Make grouped channels decoding tolerate missing `unread_count` and `unread_channels` fields in group buckets, matching the current OpenAPI schema # [5.1.0](https://github.com/GetStream/stream-chat-swift/releases/tag/5.1.0) _April 23, 2026_ diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift index ac3e91b6599..248679d8668 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift @@ -70,8 +70,8 @@ extension GroupedQueryChannelsGroupPayload: Decodable { self.init( channels: try container.decodeArrayIgnoringFailures([ChannelPayload].self, forKey: .channels), - unreadCount: try container.decode(Int.self, forKey: .unreadCount), - unreadChannels: try container.decode(Int.self, forKey: .unreadChannels) + unreadCount: try container.decodeIfPresent(Int.self, forKey: .unreadCount) ?? 0, + unreadChannels: try container.decodeIfPresent(Int.self, forKey: .unreadChannels) ?? 0 ) } } diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index e3d8593e939..382d76c9ed4 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -517,15 +517,11 @@ public class ChatClient: @unchecked Sendable { ) let endpoint: Endpoint = .groupedChannels(request: request) - apiClient.request(endpoint: endpoint) { [databaseContainer, currentUserId] result in + apiClient.request(endpoint: endpoint) { [databaseContainer] result in switch result { case let .success(payload): databaseContainer.write(converting: { session in - try Self.groupedChannels( - from: payload, - session: session, - currentUserId: currentUserId - ) + try Self.groupedChannels(from: payload, session: session) }, completion: completion) case let .failure(error): completion(.failure(error)) @@ -852,23 +848,12 @@ extension ChatClient: ConnectionDetailsProviderDelegate { extension ChatClient { private static func groupedChannels( from payload: GroupedQueryChannelsPayload, - session: DatabaseSession, - currentUserId: UserId? + session: DatabaseSession ) throws -> GroupedChannels { let groups = try payload.groups.mapValues { groupPayload in let channels = try groupPayload.channels.map { channelPayload in - _ = try session.saveChannel(payload: channelPayload) - - let unreadCount = groupedChannelUnreadCount( - from: channelPayload, - currentUserId: currentUserId - ) - - return channelPayload.asModel( - currentUserId: currentUserId, - currentlyTypingUsers: nil, - unreadCount: unreadCount - ) + let dto = try session.saveChannel(payload: channelPayload) + return try dto.asModel() } return GroupedChannelsGroup( @@ -884,33 +869,6 @@ extension ChatClient { ) } - private static func groupedChannelUnreadCount( - from payload: ChannelPayload, - currentUserId: UserId? - ) -> ChannelUnreadCount? { - guard let currentUserId, - let currentUserRead = payload.channelReads.first(where: { $0.user.id == currentUserId }) - else { - return nil - } - - let unreadMessagesCount = currentUserRead.unreadMessagesCount - guard unreadMessagesCount > 0 else { return .noUnread } - - let unreadMentionsCount = payload.messages - .sorted { $0.createdAt > $1.createdAt } - .prefix(unreadMessagesCount) - .filter { messagePayload in - messagePayload.mentionedUsers.contains { $0.id == currentUserId } - } - .count - - return ChannelUnreadCount( - messages: unreadMessagesCount, - mentions: unreadMentionsCount - ) - } - func backgroundWorker(of type: T.Type) throws -> T { if let worker = backgroundWorkers.compactMap({ $0 as? T }).first { return worker 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 25cb5cbebf5..4ab7da9b2c0 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 @@ -746,6 +750,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 8addfeafeb0..4ba20e26feb 100644 --- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents +++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents @@ -163,6 +163,7 @@ + @@ -589,4 +590,4 @@ - \ No newline at end of file + diff --git a/Sources/StreamChat/Models/CurrentUser.swift b/Sources/StreamChat/Models/CurrentUser.swift index b675c497207..b29e252090d 100644 --- a/Sources/StreamChat/Models/CurrentUser.swift +++ b/Sources/StreamChat/Models/CurrentUser.swift @@ -55,6 +55,9 @@ public class CurrentChatUser: ChatUser, @unchecked Sendable { /// 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, @unchecked Sendable { flaggedUsers: Set, flaggedMessageIDs: Set, unreadCount: UnreadCount, + groupedUnreadChannels: GroupedUnreadChannels? = nil, mutedChannels: Set, privacySettings: UserPrivacySettings, avgResponseTime: Int?, @@ -99,6 +103,7 @@ public class CurrentChatUser: ChatUser, @unchecked Sendable { 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/UnreadCount.swift b/Sources/StreamChat/Models/UnreadCount.swift index 725b39e27d5..b1b4d33c83a 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, Sendable { /// The default value representing no unread channels, messages and threads. diff --git a/Sources/StreamChat/WebSocketClient/Events/ChannelEvents.swift b/Sources/StreamChat/WebSocketClient/Events/ChannelEvents.swift index 255c9f067b3..3fb6ad7a11b 100644 --- a/Sources/StreamChat/WebSocketClient/Events/ChannelEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/ChannelEvents.swift @@ -107,7 +107,7 @@ final 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 @@ final 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 98b94a98be8..6d38ccefdcc 100644 --- a/Sources/StreamChat/WebSocketClient/Events/Event.swift +++ b/Sources/StreamChat/WebSocketClient/Events/Event.swift @@ -39,6 +39,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 cid: ChannelId { get } diff --git a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift index 004cfb8315a..f7f6d9220a5 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift @@ -36,6 +36,7 @@ final class EventPayload: Decodable, Sendable { 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" @@ -76,6 +77,7 @@ final class EventPayload: Decodable, Sendable { let lastDeliveredAt: Date? let lastDeliveredMessageId: MessageId? let unreadMessagesCount: Int? + let groupedUnreadChannels: GroupedUnreadChannels? let poll: PollPayload? let vote: PollVotePayload? @@ -105,6 +107,7 @@ final class EventPayload: Decodable, Sendable { reaction: MessageReactionPayload? = nil, watcherCount: Int? = nil, unreadCount: UnreadCountPayload? = nil, + groupedUnreadChannels: GroupedUnreadChannels? = nil, createdAt: Date? = nil, isChannelHistoryCleared: Bool? = nil, banReason: String? = nil, @@ -155,6 +158,7 @@ final class EventPayload: Decodable, Sendable { self.lastReadAt = lastReadAt self.lastReadMessageId = lastReadMessageId self.unreadMessagesCount = unreadMessagesCount + self.groupedUnreadChannels = groupedUnreadChannels self.threadPartial = threadPartial self.threadDetails = threadDetails self.poll = poll @@ -199,6 +203,7 @@ final class EventPayload: Decodable, Sendable { 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) @@ -244,6 +249,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 10d4ebfd7df..0e18304c03d 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 @@ final 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 6200017f22c..08845daaeb5 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 @@ final 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 @@ final 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 @@ final class NotificationMarkReadEventDTO: EventDTO { user: userDTO.asModel(), cid: cid, unreadCount: UnreadCount(currentUserDTO: currentUser), + groupedUnreadChannels: currentUser.groupedUnreadChannels, lastReadMessageId: lastReadMessageId, createdAt: createdAt ) @@ -233,6 +270,7 @@ final class NotificationMarkUnreadEventDTO: EventDTO { lastReadMessageId: lastReadMessageId, lastReadAt: lastReadAt, unreadCount: UnreadCount(currentUserDTO: currentUser), + groupedUnreadChannels: currentUser.groupedUnreadChannels, unreadMessagesCount: unreadMessagesCount ) } @@ -586,7 +624,7 @@ final 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 @@ final class NotificationChannelDeletedEventDTO: EventDTO { return try? NotificationChannelDeletedEvent( cid: cid, channel: channelDTO.asModel(), - createdAt: createdAt + createdAt: createdAt, + groupedUnreadChannels: session.currentUser?.groupedUnreadChannels ) } } diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift index 4fb34bde7aa..97c751fdcd1 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) { diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift index 996ecf886ff..3d82312e366 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift @@ -108,7 +108,6 @@ final class ChannelListPayload_Tests: XCTestCase { "read": [] } ], - "unread_count": 3, "unread_channels": 1 } }, @@ -120,11 +119,68 @@ final class ChannelListPayload_Tests: XCTestCase { XCTAssertEqual(payload.groups.keys.sorted(), ["all"]) XCTAssertEqual(payload.groups["all"]?.channels.map(\.channel.cid), [channelId]) - XCTAssertEqual(payload.groups["all"]?.unreadCount, 3) + XCTAssertEqual(payload.groups["all"]?.unreadCount, 0) 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"]?.unreadCount, 0) + 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 6c8553e7ccd..93be5ebe4aa 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -264,7 +264,6 @@ final class ChatClient_Tests: XCTestCase { func test_groupedQueryChannels_callsAPIClientAndReturnsGroupedChannels() { let client = ChatClient.mock(config: inMemoryStorageConfig) - client.setToken(token: try! .init(rawValue: "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiZHVtbXlDdXJyZW50VXNlciJ9.signature")) let firstCid = ChannelId.unique let secondCid = ChannelId.unique let thirdCid = ChannelId.unique @@ -316,8 +315,7 @@ final class ChatClient_Tests: XCTestCase { XCTAssertEqual(receivedGroupedChannels?.channels["all"]?.map(\.cid), [firstCid]) XCTAssertEqual(receivedGroupedChannels?.channels["new"]?.map(\.cid), [secondCid]) XCTAssertEqual(receivedGroupedChannels?.channels["current"]?.map(\.cid), [thirdCid]) - XCTAssertEqual(receivedGroupedChannels?.channels["all"]?.first?.unreadCount.messages, 10) - XCTAssertEqual(receivedGroupedChannels?.unreadCounts, ["all": 10, "new": 10, "current": 10]) + XCTAssertEqual(receivedGroupedChannels?.unreadCounts, ["all": 1, "new": 2, "current": 4]) XCTAssertEqual(receivedGroupedChannels?.unreadChannels, ["all": 1, "new": 1, "current": 2]) } 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 791528649e5..514700d28e6 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 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..770ceeae13b 100644 --- a/Tests/StreamChatTests/WebSocketClient/Events/NotificationEvents_Tests.swift +++ b/Tests/StreamChatTests/WebSocketClient/Events/NotificationEvents_Tests.swift @@ -54,6 +54,37 @@ 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", + "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 +231,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 +251,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 +269,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 +292,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 +302,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 +537,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 +558,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) } } From 90ee63e12193fe9890d0973e0b8b9c6a658db1ab Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Fri, 17 Apr 2026 14:16:08 +0200 Subject: [PATCH 08/31] Save the grouped unread channels when doing the grouped endpoint --- Sources/StreamChat/ChatClient.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index 382d76c9ed4..785a39d39b8 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -520,6 +520,10 @@ public class ChatClient: @unchecked Sendable { apiClient.request(endpoint: endpoint) { [databaseContainer] result in switch result { case let .success(payload): + databaseContainer.write { session in + let groupedUnreadChannels = payload.groups.mapValues(\.unreadChannels) + try session.saveCurrentUserGroupedUnreadChannels(groupedUnreadChannels) + } databaseContainer.write(converting: { session in try Self.groupedChannels(from: payload, session: session) }, completion: completion) From 9bcda48e1a787edc0e84575297232c2427c07cd5 Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Sun, 19 Apr 2026 22:10:05 +0200 Subject: [PATCH 09/31] Fix bug with linking --- Sources/StreamChat/Database/DTOs/ChannelDTO.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift index 40417dccbdf..4e29de8dd19 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift @@ -273,7 +273,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 { From 55a1a6f11803381718f493689807f6142edc2bbb Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Mon, 20 Apr 2026 12:53:14 +0200 Subject: [PATCH 10/31] Removed conversion to payload --- Sources/StreamChat/ChatClient.swift | 7 - .../Workers/ChannelListUpdater.swift | 343 +----------------- 2 files changed, 4 insertions(+), 346 deletions(-) diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index 785a39d39b8..cb52f9c2940 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -501,9 +501,6 @@ public class ChatClient: @unchecked Sendable { } /// Loads grouped channel groups for the app. - /// - /// The response preserves the backend-provided group keys and is converted to `ChatChannel` - /// models without persisting the data locally. public func groupedQueryChannels( limit: Int? = nil, watch: Bool = false, @@ -534,9 +531,6 @@ public class ChatClient: @unchecked Sendable { } /// Loads grouped channel groups for the app. - /// - /// The response preserves the backend-provided group keys and is converted to `ChatChannel` - /// models without persisting the data locally. public func groupedQueryChannels( limit: Int? = nil, watch: Bool = false, @@ -867,7 +861,6 @@ extension ChatClient { ) } - (session as? NSManagedObjectContext)?.rollback() return GroupedChannels( groups: groups ) diff --git a/Sources/StreamChat/Workers/ChannelListUpdater.swift b/Sources/StreamChat/Workers/ChannelListUpdater.swift index afab5cf2bfb..6477a7b2617 100644 --- a/Sources/StreamChat/Workers/ChannelListUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelListUpdater.swift @@ -52,8 +52,10 @@ class ChannelListUpdater: Worker, @unchecked Sendable { queryDTO.channels.removeAll() savedChannels = channels.compactMapLoggingError { channel in - let payload = channel.asPrefillPayload() - let channelDTO = try session.saveChannel(payload: payload, query: nil, cache: nil) + 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() } @@ -263,340 +265,3 @@ private extension ChannelListQuery { return query } } - -private extension ChatChannel { - func asPrefillPayload() -> ChannelPayload { - ChannelPayload( - channel: ChannelDetailPayload( - cid: cid, - name: name, - imageURL: imageURL, - extraData: extraData, - typeRawValue: cid.type.rawValue, - lastMessageAt: lastMessageAt, - createdAt: createdAt, - deletedAt: deletedAt, - updatedAt: updatedAt, - truncatedAt: truncatedAt, - createdBy: createdBy?.asPayload(), - config: config, - filterTags: Array(filterTags), - ownCapabilities: ownCapabilities.map(\.rawValue), - isDisabled: isDisabled, - isFrozen: isFrozen, - isBlocked: isBlocked, - isHidden: isHidden, - members: nil, - memberCount: memberCount, - messageCount: messageCount, - team: team, - cooldownDuration: cooldownDuration - ), - watcherCount: watcherCount, - watchers: lastActiveWatchers.map { $0.asPayload() }, - members: lastActiveMembers.map { $0.asPayload() }, - membership: membership?.asPayload(), - messages: latestMessages.map { $0.asPayload() }, - pendingMessages: pendingMessages.map { $0.asPayload() }, - pinnedMessages: pinnedMessages.map { $0.asPayload() }, - channelReads: reads.map { $0.asPayload() }, - isHidden: isHidden, - draft: draftMessage?.asPayload(), - activeLiveLocations: activeLiveLocations.map { $0.asPayload() }, - pushPreference: pushPreference?.asPayload() - ) - } -} - -private extension ChatUser { - func asPayload() -> UserPayload { - UserPayload( - id: id, - name: name, - imageURL: imageURL, - role: userRole, - teamsRole: teamsRole, - createdAt: userCreatedAt, - updatedAt: userUpdatedAt, - deactivatedAt: userDeactivatedAt, - lastActiveAt: lastActiveAt, - isOnline: isOnline, - isInvisible: false, - isBanned: isBanned, - teams: Array(teams), - language: language?.languageCode, - avgResponseTime: avgResponseTime, - extraData: extraData - ) - } -} - -private extension ChatChannelMember { - func asPayload() -> MemberPayload { - MemberPayload( - user: asUserPayload(), - userId: id, - role: memberRole, - createdAt: memberCreatedAt, - updatedAt: memberUpdatedAt, - banExpiresAt: banExpiresAt, - isBanned: isBannedFromChannel, - isShadowBanned: isShadowBannedFromChannel, - isInvited: isInvited, - inviteAcceptedAt: inviteAcceptedAt, - inviteRejectedAt: inviteRejectedAt, - archivedAt: archivedAt, - pinnedAt: pinnedAt, - notificationsMuted: notificationsMuted, - extraData: memberExtraData - ) - } - - private func asUserPayload() -> UserPayload { - UserPayload( - id: id, - name: name, - imageURL: imageURL, - role: userRole, - teamsRole: teamsRole, - createdAt: userCreatedAt, - updatedAt: userUpdatedAt, - deactivatedAt: userDeactivatedAt, - lastActiveAt: lastActiveAt, - isOnline: isOnline, - isInvisible: false, - isBanned: isBanned, - teams: Array(teams), - language: language?.languageCode, - avgResponseTime: avgResponseTime, - extraData: extraData - ) - } -} - -private extension ChatChannelRead { - func asPayload() -> ChannelReadPayload { - ChannelReadPayload( - user: user.asPayload(), - lastReadAt: lastReadAt, - lastReadMessageId: lastReadMessageId, - unreadMessagesCount: unreadMessagesCount, - lastDeliveredAt: lastDeliveredAt, - lastDeliveredMessageId: lastDeliveredMessageId - ) - } -} - -private extension ChatMessage { - func asPayload(depth: Int = 0) -> MessagePayload { - MessagePayload( - id: id, - cid: cid, - type: type, - user: author.asPayload(), - createdAt: createdAt, - updatedAt: updatedAt, - deletedAt: deletedAt, - text: text, - command: command, - args: arguments, - parentId: parentMessageId, - showReplyInChannel: showReplyInChannel, - quotedMessageId: quotedMessage?.id, - quotedMessage: depth < 1 ? quotedMessage?.asPayload(depth: depth + 1) : nil, - mentionedUsers: mentionedUsers.map { $0.asPayload() }, - threadParticipants: threadParticipants.map { $0.asPayload() }, - replyCount: replyCount, - extraData: extraData, - latestReactions: latestReactions.map { $0.asPayload(messageId: id) }, - ownReactions: currentUserReactions.map { $0.asPayload(messageId: id) }, - reactionScores: reactionScores, - reactionCounts: reactionCounts, - reactionGroups: reactionGroups.mapValues { $0.asPayload() }, - isSilent: isSilent, - isShadowed: isShadowed, - attachments: allAttachments.compactMap { $0.asPayload() }, - channel: nil, - pinned: isPinned, - pinnedBy: pinDetails?.pinnedBy.asPayload(), - pinnedAt: pinDetails?.pinnedAt, - pinExpires: pinDetails?.expiresAt, - translations: translations, - originalLanguage: originalLanguage?.languageCode, - moderation: moderationDetails?.asPayload(), - moderationDetails: moderationDetails?.asPayload(), - messageTextUpdatedAt: textUpdatedAt, - poll: poll?.asPayload(), - draft: draftReply?.asPayload(), - reminder: reminder?.asPayload(cid: cid, messageId: id), - location: sharedLocation?.asPayload(), - member: MemberInfoPayload(channelRole: channelRole), - deletedForMe: deletedForMe - ) - } -} - -private extension ChatMessageReaction { - func asPayload(messageId: MessageId) -> MessageReactionPayload { - MessageReactionPayload( - type: type, - score: score, - messageId: messageId, - createdAt: createdAt, - updatedAt: updatedAt, - user: author.asPayload(), - extraData: extraData - ) - } -} - -private extension ChatMessageReactionGroup { - func asPayload() -> MessageReactionGroupPayload { - MessageReactionGroupPayload( - sumScores: sumScores, - count: count, - firstReactionAt: firstReactionAt, - lastReactionAt: lastReactionAt - ) - } -} - -private extension AnyChatMessageAttachment { - func asPayload() -> MessageAttachmentPayload? { - guard let rawPayload = try? JSONDecoder.stream.decode(RawJSON.self, from: payload) else { - return nil - } - - return MessageAttachmentPayload(type: type, payload: rawPayload) - } -} - -private extension DraftMessage { - func asPayload(depth: Int = 0) -> DraftPayload { - DraftPayload( - cid: cid, - channelPayload: nil, - createdAt: createdAt, - message: DraftMessagePayload( - id: id, - text: text, - command: command, - args: arguments, - showReplyInChannel: showReplyInChannel, - mentionedUsers: mentionedUsers.map { $0.asPayload() }, - extraData: extraData, - attachments: attachments.compactMap { $0.asPayload() }, - isSilent: isSilent - ), - quotedMessage: depth < 1 ? quotedMessage?.asPayload(depth: depth + 1) : nil, - parentId: threadId, - parentMessage: nil - ) - } -} - -private extension MessageReminderInfo { - func asPayload(cid: ChannelId?, messageId: MessageId) -> ReminderPayload? { - guard let cid else { return nil } - return ReminderPayload( - channelCid: cid, - messageId: messageId, - remindAt: remindAt, - createdAt: createdAt, - updatedAt: updatedAt - ) - } -} - -private extension SharedLocation { - func asPayload() -> SharedLocationPayload { - SharedLocationPayload( - channelId: channelId.rawValue, - messageId: messageId, - userId: userId, - latitude: latitude, - longitude: longitude, - createdAt: createdAt, - updatedAt: updatedAt, - endAt: endAt, - createdByDeviceId: createdByDeviceId - ) - } -} - -private extension PushPreference { - func asPayload() -> PushPreferencePayload { - PushPreferencePayload( - chatLevel: level.rawValue, - disabledUntil: disabledUntil - ) - } -} - -private extension MessageModerationDetails { - func asPayload() -> MessageModerationDetailsPayload { - MessageModerationDetailsPayload( - originalText: originalText, - action: action.rawValue, - textHarms: textHarms, - imageHarms: imageHarms, - blocklistMatched: blocklistMatched, - semanticFilterMatched: semanticFilterMatched, - platformCircumvented: platformCircumvented - ) - } -} - -private extension Poll { - func asPayload() -> PollPayload { - PollPayload( - allowAnswers: allowAnswers, - allowUserSuggestedOptions: allowUserSuggestedOptions, - answersCount: answersCount, - createdAt: createdAt, - createdById: createdBy?.id ?? "", - description: pollDescription ?? "", - enforceUniqueVote: enforceUniqueVote, - id: id, - name: name, - updatedAt: updatedAt ?? createdAt, - voteCount: voteCount, - latestAnswers: latestAnswers.map { Optional($0.asPayload()) }, - options: options.map { Optional($0.asPayload()) }, - ownVotes: ownVotes.map { Optional($0.asPayload()) }, - custom: extraData, - latestVotesByOption: Dictionary( - uniqueKeysWithValues: options.map { option in - (option.id, option.latestVotes.map { $0.asPayload() }) - } - ), - voteCountsByOption: voteCountsByOption ?? [:], - isClosed: isClosed, - maxVotesAllowed: maxVotesAllowed, - votingVisibility: votingVisibility?.rawValue, - createdBy: createdBy?.asPayload() - ) - } -} - -private extension PollOption { - func asPayload() -> PollOptionPayload { - PollOptionPayload(id: id, text: text, custom: extraData) - } -} - -private extension PollVote { - func asPayload() -> PollVotePayload { - PollVotePayload( - createdAt: createdAt, - id: id, - optionId: optionId, - pollId: pollId, - updatedAt: updatedAt, - answerText: answerText, - isAnswer: isAnswer, - userId: user?.id, - user: user?.asPayload() - ) - } -} From 965a983b6fcba67b075fa5d1f8d59191f9fdc487 Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Mon, 20 Apr 2026 13:06:08 +0200 Subject: [PATCH 11/31] Remove unused code --- Sources/StreamChat/ChatClient.swift | 9 --------- Tests/StreamChatTests/ChatClient_Tests.swift | 5 ----- 2 files changed, 14 deletions(-) diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index cb52f9c2940..113796a4187 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -885,15 +885,6 @@ public struct GroupedChannels: Equatable { /// The grouped channel groups returned by the backend, keyed by group name. public let groups: [String: GroupedChannelsGroup] - /// Convenience access to each group's channels, keyed by group name. - public var channels: [String: [ChatChannel]] { groups.mapValues(\.channels) } - - /// Convenience access to each group's unread message count, keyed by group name. - public var unreadCounts: [String: Int] { groups.mapValues(\.unreadCount) } - - /// Convenience access to each group's unread channel count, keyed by group name. - public var unreadChannels: [String: Int] { groups.mapValues(\.unreadChannels) } - public init( groups: [String: GroupedChannelsGroup] ) { diff --git a/Tests/StreamChatTests/ChatClient_Tests.swift b/Tests/StreamChatTests/ChatClient_Tests.swift index 93be5ebe4aa..f34154f4622 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -312,11 +312,6 @@ final class ChatClient_Tests: XCTestCase { XCTAssertNil(receivedError) XCTAssertEqual(receivedGroupedChannels?.groups.keys.sorted(), ["all", "current", "new"]) - XCTAssertEqual(receivedGroupedChannels?.channels["all"]?.map(\.cid), [firstCid]) - XCTAssertEqual(receivedGroupedChannels?.channels["new"]?.map(\.cid), [secondCid]) - XCTAssertEqual(receivedGroupedChannels?.channels["current"]?.map(\.cid), [thirdCid]) - XCTAssertEqual(receivedGroupedChannels?.unreadCounts, ["all": 1, "new": 2, "current": 4]) - XCTAssertEqual(receivedGroupedChannels?.unreadChannels, ["all": 1, "new": 1, "current": 2]) } func test_groupedChannelsGroup_normalizesUnreadTotalsFromChannels() { From 55ca14000df2de652b5cf70b2007164d19dba29d Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Mon, 20 Apr 2026 14:34:32 +0200 Subject: [PATCH 12/31] Fix tests --- .../ChannelListController_Tests.swift | 12 +++++++- .../Events/NotificationEvents_Tests.swift | 28 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift index 53c83c03a9c..d01d56825a9 100644 --- a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift @@ -2221,7 +2221,17 @@ final class ChannelListController_Tests: XCTestCase { } private func makePrefilledChannel(cid: ChannelId) -> ChatChannel { - .mock( + 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), diff --git a/Tests/StreamChatTests/WebSocketClient/Events/NotificationEvents_Tests.swift b/Tests/StreamChatTests/WebSocketClient/Events/NotificationEvents_Tests.swift index 770ceeae13b..96fc8bdd1e6 100644 --- a/Tests/StreamChatTests/WebSocketClient/Events/NotificationEvents_Tests.swift +++ b/Tests/StreamChatTests/WebSocketClient/Events/NotificationEvents_Tests.swift @@ -59,6 +59,34 @@ final class NotificationsEvents_Tests: XCTestCase { { "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", From 9bf95baca17abbfa9923feafb63917b357652f95 Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Tue, 21 Apr 2026 10:12:28 +0200 Subject: [PATCH 13/31] Remove message unread count from grouped response --- Sources/StreamChat/ChatClient.swift | 7 ------- Tests/StreamChatTests/ChatClient_Tests.swift | 1 - 2 files changed, 8 deletions(-) diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index 113796a4187..48099d0784a 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -897,9 +897,6 @@ public struct GroupedChannelsGroup: Equatable { /// The channels that belong to this group. public let channels: [ChatChannel] - /// The total unread message count across the group. - public let unreadCount: Int - /// The total unread channel count in the group. public let unreadChannels: Int @@ -909,16 +906,12 @@ public struct GroupedChannelsGroup: Equatable { unreadChannels: Int ) { self.channels = channels - let derivedUnreadCount = channels.reduce(into: 0) { partialResult, channel in - partialResult += channel.unreadCount.messages - } let derivedUnreadChannels = channels.reduce(into: 0) { partialResult, channel in if channel.unreadCount.messages > 0 { partialResult += 1 } } - self.unreadCount = max(unreadCount, derivedUnreadCount) self.unreadChannels = max(unreadChannels, derivedUnreadChannels) } } diff --git a/Tests/StreamChatTests/ChatClient_Tests.swift b/Tests/StreamChatTests/ChatClient_Tests.swift index f34154f4622..02e85e65354 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -334,7 +334,6 @@ final class ChatClient_Tests: XCTestCase { unreadChannels: 0 ) - XCTAssertEqual(group.unreadCount, 4) XCTAssertEqual(group.unreadChannels, 2) } From 819226e69c827bf3850804519328d9a025e1daa3 Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Tue, 21 Apr 2026 10:15:54 +0200 Subject: [PATCH 14/31] Removed unread count from the payload --- .../APIClient/Endpoints/Payloads/ChannelListPayload.swift | 3 --- Sources/StreamChat/ChatClient.swift | 2 -- .../Endpoints/Payloads/ChannelListPayload_Tests.swift | 2 -- Tests/StreamChatTests/ChatClient_Tests.swift | 4 ---- 4 files changed, 11 deletions(-) diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift index 248679d8668..dc4eb728c1b 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift @@ -54,14 +54,12 @@ extension GroupedQueryChannelsPayload: Decodable { struct GroupedQueryChannelsGroupPayload { let channels: [ChannelPayload] - let unreadCount: Int let unreadChannels: Int } extension GroupedQueryChannelsGroupPayload: Decodable { enum CodingKeys: String, CodingKey { case channels - case unreadCount = "unread_count" case unreadChannels = "unread_channels" } @@ -70,7 +68,6 @@ extension GroupedQueryChannelsGroupPayload: Decodable { self.init( channels: try container.decodeArrayIgnoringFailures([ChannelPayload].self, forKey: .channels), - unreadCount: try container.decodeIfPresent(Int.self, forKey: .unreadCount) ?? 0, unreadChannels: try container.decodeIfPresent(Int.self, forKey: .unreadChannels) ?? 0 ) } diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index 48099d0784a..cbe0dc9257b 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -856,7 +856,6 @@ extension ChatClient { return GroupedChannelsGroup( channels: channels, - unreadCount: groupPayload.unreadCount, unreadChannels: groupPayload.unreadChannels ) } @@ -902,7 +901,6 @@ public struct GroupedChannelsGroup: Equatable { public init( channels: [ChatChannel], - unreadCount: Int, unreadChannels: Int ) { self.channels = channels diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift index 3d82312e366..f37199f1950 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift @@ -119,7 +119,6 @@ final class ChannelListPayload_Tests: XCTestCase { XCTAssertEqual(payload.groups.keys.sorted(), ["all"]) XCTAssertEqual(payload.groups["all"]?.channels.map(\.channel.cid), [channelId]) - XCTAssertEqual(payload.groups["all"]?.unreadCount, 0) XCTAssertEqual(payload.groups["all"]?.unreadChannels, 1) XCTAssertEqual(payload.duration, "12ms") } @@ -177,7 +176,6 @@ final class ChannelListPayload_Tests: XCTestCase { let payload = try JSONDecoder.default.decode(GroupedQueryChannelsPayload.self, from: json) XCTAssertEqual(payload.groups["expired"]?.channels.map(\.channel.cid), [channelId]) - XCTAssertEqual(payload.groups["expired"]?.unreadCount, 0) XCTAssertEqual(payload.groups["expired"]?.unreadChannels, 0) } diff --git a/Tests/StreamChatTests/ChatClient_Tests.swift b/Tests/StreamChatTests/ChatClient_Tests.swift index 02e85e65354..96276f0fa30 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -274,17 +274,14 @@ final class ChatClient_Tests: XCTestCase { groups: [ "all": .init( channels: [dummyPayload(with: firstCid)], - unreadCount: 1, unreadChannels: 1 ), "new": .init( channels: [dummyPayload(with: secondCid)], - unreadCount: 2, unreadChannels: 1 ), "current": .init( channels: [dummyPayload(with: thirdCid)], - unreadCount: 4, unreadChannels: 2 ) ], @@ -330,7 +327,6 @@ final class ChatClient_Tests: XCTestCase { let group = GroupedChannelsGroup( channels: [firstChannel, secondChannel, thirdChannel], - unreadCount: 0, unreadChannels: 0 ) From f872fb141bdba4c9b105e14aee097c12fb5f3453 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Wed, 22 Apr 2026 11:46:17 +0300 Subject: [PATCH 15/31] Add messageCount filter key --- Sources/StreamChat/Query/ChannelListQuery.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/StreamChat/Query/ChannelListQuery.swift b/Sources/StreamChat/Query/ChannelListQuery.swift index fe1f810e1c2..8fcd2a3bfa6 100644 --- a/Sources/StreamChat/Query/ChannelListQuery.swift +++ b/Sources/StreamChat/Query/ChannelListQuery.swift @@ -245,6 +245,10 @@ 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` + static var messageCount: FilterKey { .init(rawValue: "message_count", keyPathString: #keyPath(ChannelDTO.messageCount)) } + /// A filter key for matching the `team` value. /// Supported operators: `equal` static var team: FilterKey { .init(rawValue: "team", keyPathString: #keyPath(ChannelDTO.team)) } From 94487d1abbb8a3f4284c78f664220ac3fa706b2a Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Wed, 22 Apr 2026 13:46:33 +0300 Subject: [PATCH 16/31] Tidy ChatClient by reorganising query grouped channels --- CHANGELOG.md | 6 +- Sources/StreamChat/ChatClient.swift | 131 +++++------------- .../StreamChat/Models/GroupedChannels.swift | 40 ++++++ .../Workers/ChannelListUpdater.swift | 59 +++++++- Tests/StreamChatTests/ChatClient_Tests.swift | 5 +- 5 files changed, 135 insertions(+), 106 deletions(-) create mode 100644 Sources/StreamChat/Models/GroupedChannels.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b819f02472..cbd03ab1981 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming ### ✅ Added -- Add `ChatChannelListController.prefill(channels:completion:)` for priming controller-local channel data before the first synchronize call while preserving normal pagination, observation, and offline refresh behavior -- Add `ChatClient.groupedQueryChannels(limit:watch:presence:)` to fetch grouped channel groups as `GroupedChannels`, preserving backend group keys and exposing normalized per-group channels and unread counts for integrators -- Add optional `groupedUnreadChannels` data to grouped unread websocket events and persist it on `CurrentChatUser` for integrators +- Add `ChatChannelListController.prefill(channels:completion:)` for priming controller-local channel data +- Add `ChatClient.queryGroupedChannels(limit:watch:presence:)` to fetch grouped channels with per group unread counts +- Add optional `groupedUnreadChannels` data to relevant web-socket events and to `CurrentChatUser` ### 🔄 Changed - Make grouped channels decoding tolerate missing `unread_count` and `unread_channels` fields in group buckets, matching the current OpenAPI schema diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index cbe0dc9257b..a8f8cb1d65b 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -500,49 +500,6 @@ public class ChatClient: @unchecked Sendable { authenticationRepository.setToken(token: token, completeTokenWaiters: true) } - /// Loads grouped channel groups for the app. - public func groupedQueryChannels( - limit: Int? = nil, - watch: Bool = false, - presence: Bool = false, - completion: @escaping (Result) -> Void - ) { - let request = GroupedQueryChannelsRequestBody( - limit: limit, - watch: watch, - presence: presence - ) - let endpoint: Endpoint = .groupedChannels(request: request) - - apiClient.request(endpoint: endpoint) { [databaseContainer] result in - switch result { - case let .success(payload): - databaseContainer.write { session in - let groupedUnreadChannels = payload.groups.mapValues(\.unreadChannels) - try session.saveCurrentUserGroupedUnreadChannels(groupedUnreadChannels) - } - databaseContainer.write(converting: { session in - try Self.groupedChannels(from: payload, session: session) - }, completion: completion) - case let .failure(error): - completion(.failure(error)) - } - } - } - - /// Loads grouped channel groups for the app. - public func groupedQueryChannels( - limit: Int? = nil, - watch: Bool = false, - presence: Bool = false - ) async throws -> GroupedChannels { - try await withCheckedThrowingContinuation { continuation in - groupedQueryChannels(limit: limit, watch: watch, presence: presence) { result in - continuation.resume(with: result) - } - } - } - /// Disconnects the chat client from the chat servers. No further updates from the servers /// are received. public func disconnect(completion: @escaping @MainActor () -> Void) { @@ -662,7 +619,7 @@ public class ChatClient: @unchecked Sendable { 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. @@ -692,6 +649,36 @@ public class ChatClient: @unchecked Sendable { } } + // 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. @@ -844,27 +831,6 @@ extension ChatClient: ConnectionDetailsProviderDelegate { } extension ChatClient { - private static func groupedChannels( - from payload: GroupedQueryChannelsPayload, - session: DatabaseSession - ) throws -> GroupedChannels { - let groups = try payload.groups.mapValues { groupPayload in - let channels = try groupPayload.channels.map { channelPayload in - let dto = try session.saveChannel(payload: channelPayload) - return try dto.asModel() - } - - return GroupedChannelsGroup( - channels: channels, - unreadChannels: groupPayload.unreadChannels - ) - } - - return GroupedChannels( - groups: groups - ) - } - func backgroundWorker(of type: T.Type) throws -> T { if let worker = backgroundWorkers.compactMap({ $0 as? T }).first { return worker @@ -879,41 +845,6 @@ extension ChatClient { } } -/// A grouped channels response returned by `ChatClient.groupedQueryChannels`. -public struct GroupedChannels: Equatable { - /// The grouped channel groups returned by the backend, keyed by group name. - public let groups: [String: GroupedChannelsGroup] - - public init( - groups: [String: GroupedChannelsGroup] - ) { - self.groups = groups - } -} - -/// A grouped channels group returned by `ChatClient.groupedQueryChannels`. -public struct GroupedChannelsGroup: Equatable { - /// The channels that belong to this group. - public let channels: [ChatChannel] - - /// The total unread channel count in the group. - public let unreadChannels: Int - - public init( - channels: [ChatChannel], - unreadChannels: Int - ) { - self.channels = channels - let derivedUnreadChannels = channels.reduce(into: 0) { partialResult, channel in - if channel.unreadCount.messages > 0 { - partialResult += 1 - } - } - - self.unreadChannels = max(unreadChannels, derivedUnreadChannels) - } -} - extension ClientError { public final class MissingLocalStorageURL: ClientError, @unchecked Sendable { override public var localizedDescription: String { "The URL provided in ChatClientConfig is `nil`." } diff --git a/Sources/StreamChat/Models/GroupedChannels.swift b/Sources/StreamChat/Models/GroupedChannels.swift new file mode 100644 index 00000000000..5b85dd54645 --- /dev/null +++ b/Sources/StreamChat/Models/GroupedChannels.swift @@ -0,0 +1,40 @@ +// +// 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] + + public init( + groups: [String: GroupedChannelsGroup] + ) { + self.groups = groups + } +} + +/// A grouped channels group returned by `ChatClient.queryGroupedChannels`. +public struct GroupedChannelsGroup: Equatable { + /// The channels that belong to this group. + public let channels: [ChatChannel] + + /// The total unread channel count in the group. + public let unreadChannels: Int + + public init( + channels: [ChatChannel], + unreadChannels: Int + ) { + 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/Workers/ChannelListUpdater.swift b/Sources/StreamChat/Workers/ChannelListUpdater.swift index 6477a7b2617..14385334a6f 100644 --- a/Sources/StreamChat/Workers/ChannelListUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelListUpdater.swift @@ -193,6 +193,51 @@ class ChannelListUpdater: Worker, @unchecked Sendable { 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) + + let groups = try payload.groups.mapValues { groupPayload in + let channels = try groupPayload.channels.map { channelPayload in + let dto = try session.saveChannel(payload: channelPayload) + return try dto.asModel() + } + return GroupedChannelsGroup( + 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 { @@ -241,7 +286,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/Tests/StreamChatTests/ChatClient_Tests.swift b/Tests/StreamChatTests/ChatClient_Tests.swift index 96276f0fa30..a7104c74bd1 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -262,8 +262,9 @@ final class ChatClient_Tests: XCTestCase { XCTAssert(testEnv.apiClient?.init_requestEncoder is RequestEncoder_Spy) } - func test_groupedQueryChannels_callsAPIClientAndReturnsGroupedChannels() { + 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 @@ -292,7 +293,7 @@ final class ChatClient_Tests: XCTestCase { var receivedGroupedChannels: GroupedChannels? var receivedError: Error? - client.groupedQueryChannels(limit: 4, watch: true, presence: false) { result in + client.queryGroupedChannels(limit: 4, watch: true, presence: false) { result in switch result { case let .success(groupedChannels): receivedGroupedChannels = groupedChannels From 9a193c4327cb6e5a9f4200914dc923710bc0fe26 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Wed, 22 Apr 2026 13:57:19 +0300 Subject: [PATCH 17/31] User class for request and response types, tidy changelog --- CHANGELOG.md | 8 ++-- .../Payloads/ChannelListPayload.swift | 44 +++++++++++-------- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbd03ab1981..53df97778e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming ### ✅ Added -- Add `ChatChannelListController.prefill(channels:completion:)` for priming controller-local channel data -- Add `ChatClient.queryGroupedChannels(limit:watch:presence:)` to fetch grouped channels with per group unread counts -- Add optional `groupedUnreadChannels` data to relevant web-socket events and to `CurrentChatUser` +- Add `ChatChannelListController.prefill(channels: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 +- 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) # [5.1.0](https://github.com/GetStream/stream-chat-swift/releases/tag/5.1.0) _April 23, 2026_ diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift index dc4eb728c1b..1d0e558f3fc 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift @@ -25,51 +25,57 @@ extension ChannelListPayload: Decodable { } } -struct GroupedQueryChannelsRequestBody: Encodable { +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 + } } -struct GroupedQueryChannelsPayload { +final class GroupedQueryChannelsPayload: Decodable { let groups: [String: GroupedQueryChannelsGroupPayload] let duration: String -} -extension GroupedQueryChannelsPayload: Decodable { + init(groups: [String: GroupedQueryChannelsGroupPayload], duration: String) { + self.groups = groups + self.duration = duration + } + enum CodingKeys: String, CodingKey { case groups case duration } - init(from decoder: Decoder) throws { + required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - - self.init( - groups: try container.decode([String: GroupedQueryChannelsGroupPayload].self, forKey: .groups), - duration: try container.decode(String.self, forKey: .duration) - ) + groups = try container.decode([String: GroupedQueryChannelsGroupPayload].self, forKey: .groups) + duration = try container.decode(String.self, forKey: .duration) } } -struct GroupedQueryChannelsGroupPayload { +final class GroupedQueryChannelsGroupPayload: Decodable { let channels: [ChannelPayload] let unreadChannels: Int -} -extension GroupedQueryChannelsGroupPayload: Decodable { + init(channels: [ChannelPayload], unreadChannels: Int) { + self.channels = channels + self.unreadChannels = unreadChannels + } + enum CodingKeys: String, CodingKey { case channels case unreadChannels = "unread_channels" } - init(from decoder: Decoder) throws { + required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - - self.init( - channels: try container.decodeArrayIgnoringFailures([ChannelPayload].self, forKey: .channels), - unreadChannels: try container.decodeIfPresent(Int.self, forKey: .unreadChannels) ?? 0 - ) + channels = try container.decodeArrayIgnoringFailures([ChannelPayload].self, forKey: .channels) + unreadChannels = try container.decodeIfPresent(Int.self, forKey: .unreadChannels) ?? 0 } } From 8e4f47b65aebaae0873b93c58ba29cb411401b94 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Wed, 22 Apr 2026 16:18:48 +0300 Subject: [PATCH 18/31] Use local message count for filtering when server provided is not present (avoids manually keeping the message count up to date which is part of the count_messages app setting) --- .../StreamChat/Database/DTOs/ChannelDTO.swift | 16 +--- .../StreamChat/Database/DatabaseSession.swift | 3 - .../StreamChat/Query/ChannelListQuery.swift | 30 +++++- .../Database/DatabaseSession_Tests.swift | 85 +++++++---------- .../Query/ChannelListFilterScope_Tests.swift | 91 +++++++++++++++++++ 5 files changed, 155 insertions(+), 70 deletions(-) diff --git a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift index 4e29de8dd19..320905b3478 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift @@ -350,11 +350,7 @@ extension NSManagedObjectContext { dto.reads.formUnion(reads) try payload.messages.forEach { _ = try saveMessage(payload: $0, channelDTO: dto, syncOwnReactions: true, cache: cache) } - if payload.channel.messageCount == nil, - let inferredMessageCount = inferredMessageCount(for: payload, existingMessageCount: dto.messageCount?.intValue) { - dto.messageCount = NSNumber(value: inferredMessageCount) - } - + var pendingMessages = Set() try payload.pendingMessages?.forEach { let pending = try saveMessage( @@ -437,16 +433,6 @@ extension NSManagedObjectContext { return dto } - private func inferredMessageCount(for payload: ChannelPayload, existingMessageCount: Int?) -> Int? { - let minimumMessageCountFromPayload = max( - payload.messages.count, - payload.channel.lastMessageAt == nil ? 0 : 1 - ) - let inferredMessageCount = max(existingMessageCount ?? 0, minimumMessageCountFromPayload) - guard inferredMessageCount > 0 || existingMessageCount != nil else { return nil } - return inferredMessageCount - } - func channel(cid: ChannelId) -> ChannelDTO? { ChannelDTO.load(cid: cid, context: self) } diff --git a/Sources/StreamChat/Database/DatabaseSession.swift b/Sources/StreamChat/Database/DatabaseSession.swift index 4ab7da9b2c0..2f12f1652ca 100644 --- a/Sources/StreamChat/Database/DatabaseSession.swift +++ b/Sources/StreamChat/Database/DatabaseSession.swift @@ -870,9 +870,6 @@ extension DatabaseSession { if let messageCount = payload.channelMessageCount { channelDTO.messageCount = NSNumber(value: messageCount) - } else if isNewMessage, !messageExistsLocally { - let currentMessageCount = channelDTO.messageCount?.intValue ?? 0 - channelDTO.messageCount = NSNumber(value: currentMessageCount + 1) } } diff --git a/Sources/StreamChat/Query/ChannelListQuery.swift b/Sources/StreamChat/Query/ChannelListQuery.swift index 8fcd2a3bfa6..cc8aa8c516c 100644 --- a/Sources/StreamChat/Query/ChannelListQuery.swift +++ b/Sources/StreamChat/Query/ChannelListQuery.swift @@ -247,7 +247,35 @@ public extension FilterKey where Scope == ChannelListFilterScope { /// A filter key for matching the `messageCount` value. /// Supported operators: `equal`, `greaterThan`, `lessThan`, `greaterOrEqual`, `lessOrEqual` - static var messageCount: FilterKey { .init(rawValue: "message_count", keyPathString: #keyPath(ChannelDTO.messageCount)) } + /// + /// 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` diff --git a/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift b/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift index 514700d28e6..8fb4116d40e 100644 --- a/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift +++ b/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift @@ -564,53 +564,6 @@ final class DatabaseSession_Tests: XCTestCase { XCTAssertEqual(channelDTO.previewMessage?.id, previewMessage.id) } - func test_saveChannel_whenMessageCountIsMissing_infersItFromPayloadMessages() throws { - let firstMessage: MessagePayload = .dummy( - messageId: .unique, - authorUserId: .unique - ) - let secondMessage: MessagePayload = .dummy( - messageId: .unique, - authorUserId: .unique, - createdAt: firstMessage.createdAt.addingTimeInterval(10) - ) - - let channel: ChannelPayload = .dummy( - channel: .dummy( - cid: .unique, - lastMessageAt: secondMessage.createdAt, - messageCount: nil - ), - messages: [firstMessage, secondMessage] - ) - - try database.writeSynchronously { session in - try session.saveChannel(payload: channel) - } - - let channelDTO = try XCTUnwrap(database.viewContext.channel(cid: channel.channel.cid)) - XCTAssertEqual(channelDTO.messageCount?.intValue, 2) - } - - func test_saveChannel_whenMessageCountIsMissingAndLastMessageExists_infersAtLeastOneMessage() throws { - let lastMessageAt = Date() - let channel: ChannelPayload = .dummy( - channel: .dummy( - cid: .unique, - lastMessageAt: lastMessageAt, - messageCount: nil - ), - messages: [] - ) - - try database.writeSynchronously { session in - try session.saveChannel(payload: channel) - } - - let channelDTO = try XCTUnwrap(database.viewContext.channel(cid: channel.channel.cid)) - XCTAssertEqual(channelDTO.messageCount?.intValue, 1) - } - func test_saveEvent_whenMessageNewEventComes_updatesChannelPreview() throws { // GIVEN let previewMessage: MessagePayload = .dummy( @@ -800,7 +753,7 @@ final class DatabaseSession_Tests: XCTestCase { XCTAssertEqual(channelModel.latestMessages.first?.id, newMessage.id) } - func test_saveEvent_whenMessageNewEventComesWithoutChannelMessageCount_incrementsChannelMessageCount() throws { + func test_saveEvent_whenMessageNewEventComesWithoutChannelMessageCount_keepsExistingChannelMessageCount() throws { let existingMessage: MessagePayload = .dummy( messageId: .unique, authorUserId: .unique @@ -832,10 +785,10 @@ final class DatabaseSession_Tests: XCTestCase { } let channelDTO = try XCTUnwrap(database.viewContext.channel(cid: channel.channel.cid)) - XCTAssertEqual(channelDTO.messageCount?.intValue, 2) + XCTAssertEqual(channelDTO.messageCount?.intValue, 1) } - func test_saveEvent_whenNotificationMessageNewEventComesWithoutChannelMessageCount_incrementsChannelMessageCount() throws { + func test_saveEvent_whenNotificationMessageNewEventComesWithoutChannelMessageCount_keepsExistingChannelMessageCount() throws { let existingMessage: MessagePayload = .dummy( messageId: .unique, authorUserId: .unique @@ -867,7 +820,37 @@ final class DatabaseSession_Tests: XCTestCase { } let channelDTO = try XCTUnwrap(database.viewContext.channel(cid: channel.channel.cid)) - XCTAssertEqual(channelDTO.messageCount?.intValue, 2) + 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_whenMessageDeletedEvent_latestMessagesFirstStillReturnsDeletedMessage() throws { 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]) + } } From 9bf6d49d3c6c5f2c37708f555c29ea2ab5f69bce Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 23 Apr 2026 09:48:42 +0300 Subject: [PATCH 19/31] Sync pre-filled channel list controllers with query grouped channels endpoint --- .../ChannelListController.swift | 7 +- .../Repositories/SyncOperations.swift | 34 +++++++++ .../Repositories/SyncRepository.swift | 19 ++++- .../Spy/ChannelListUpdater_Spy.swift | 21 +++++ .../Repositories/SyncRepository_Tests.swift | 76 +++++++++++++++++++ 5 files changed, 153 insertions(+), 4 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift index f15d876cd40..f7c709c90b1 100644 --- a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift +++ b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift @@ -70,7 +70,11 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt /// A Boolean value that returns whether pagination is finished public private(set) var hasLoadedAllPreviousChannels: Bool = false private var loadedChannelsCount = 0 - private var shouldSkipInitialRemoteUpdate = false + @Atomic private var shouldSkipInitialRemoteUpdate = false + /// `true` once `prefill(...)` has successfully populated this controller. Stays `true` + /// for the controller's lifetime so `SyncRepository` can route its reconnect-refresh + /// through `queryGroupedChannels` instead of the standard `/channels` query. + @Atomic var usesGroupedChannelsForSync = false /// A type-erased delegate. var multicastDelegate: MulticastDelegate = .init() { @@ -217,6 +221,7 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt case let .success(savedChannels): self?.loadedChannelsCount = savedChannels.count self?.shouldSkipInitialRemoteUpdate = true + self?.usesGroupedChannelsForSync = 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 diff --git a/Sources/StreamChat/Repositories/SyncOperations.swift b/Sources/StreamChat/Repositories/SyncOperations.swift index f134205e2f7..07e9d25c653 100644 --- a/Sources/StreamChat/Repositories/SyncOperations.swift +++ b/Sources/StreamChat/Repositories/SyncOperations.swift @@ -109,6 +109,40 @@ 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) + let controllerChannelIds = controllers.flatMap { $0.channels.map(\.cid) } + context.synchedChannelIds.formUnion(returnedChannelIds) + context.synchedChannelIds.formUnion(controllerChannelIds) + log.debug( + "Synced \(returnedChannelIds.count) grouped channels across \(groupedChannels.groups.count) group(s)", + subsystems: .offlineSupport + ) + 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 5bb7f763534..6c86087a451 100644 --- a/Sources/StreamChat/Repositories/SyncRepository.swift +++ b/Sources/StreamChat/Repositories/SyncRepository.swift @@ -160,7 +160,8 @@ class SyncRepository: @unchecked Sendable { /// /// 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: @unchecked Sendable { // 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.usesGroupedChannelsForSync } + let standardControllers = allControllers.filter { !$0.usesGroupedChannelsForSync } + 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/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift b/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift index 6ef50a919e2..c7cc30de1bc 100644 --- a/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift +++ b/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift @@ -22,6 +22,9 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable @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)? var startWatchingChannels_callCount = 0 @@ -46,6 +49,8 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable fetch_completion = nil refreshLoadedChannels_channelCounts.removeAll() + queryGroupedChannels_callCount = 0 + queryGroupedChannels_result = nil markAllRead_completion = nil startWatchingChannels_cids.removeAll() @@ -94,6 +99,22 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable 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/Repositories/SyncRepository_Tests.swift b/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift index f88fee86d82..646ad6ab5aa 100644 --- a/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift +++ b/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift @@ -227,6 +227,82 @@ 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 + ) + + let chatListController = ChatChannelListController_Mock(query: .init(filter: .exists(.cid)), client: client) + chatListController.state_mock = .remoteDataFetched + chatListController.channels_mock = [.mock(cid: cid)] + chatListController.usesGroupedChannelsForSync = true + repository.startTrackingChannelListController(chatListController) + channelListUpdater.queryGroupedChannels_result = .success(.init(groups: [:])) + + waitForSyncLocalStateRun() + + XCTAssertEqual(channelListUpdater.queryGroupedChannels_callCount, 1) + XCTAssertNotCall("refreshLoadedChannels(completion:)", on: chatListController) + // 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 + ) + + let prefilledController = ChatChannelListController_Mock(query: .init(filter: .exists(.cid)), client: client) + prefilledController.state_mock = .remoteDataFetched + prefilledController.channels_mock = [.mock(cid: prefilledCid)] + prefilledController.usesGroupedChannelsForSync = true + 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) + + channelListUpdater.queryGroupedChannels_result = .success(.init(groups: [:])) + + waitForSyncLocalStateRun() + + XCTAssertEqual(channelListUpdater.queryGroupedChannels_callCount, 1) + XCTAssertNotCall("refreshLoadedChannels(completion:)", on: prefilledController) + XCTAssertCall("refreshLoadedChannels(completion:)", on: standardController, times: 1) + } + + 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 From bb55a755fa97bc146765862a167a6eecb0c1ff99 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 23 Apr 2026 12:34:54 +0300 Subject: [PATCH 20/31] Update fetch limit for avoiding to keep track of prefilled channel count --- .../ChannelListController.swift | 28 ++++++------ .../BackgroundDatabaseObserver.swift | 17 +++++++ .../ChannelListController_Tests.swift | 45 +++++++++++++++++++ 3 files changed, 77 insertions(+), 13 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift index f7c709c90b1..cabc50da511 100644 --- a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift +++ b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift @@ -69,12 +69,12 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt /// A Boolean value that returns whether pagination is finished public private(set) var hasLoadedAllPreviousChannels: Bool = false - private var loadedChannelsCount = 0 @Atomic private var shouldSkipInitialRemoteUpdate = false /// `true` once `prefill(...)` has successfully populated this controller. Stays `true` /// for the controller's lifetime so `SyncRepository` can route its reconnect-refresh /// through `queryGroupedChannels` instead of the standard `/channels` query. @Atomic var usesGroupedChannelsForSync = false + @Atomic private var prefilledChannelCount: Int = 0 /// A type-erased delegate. var multicastDelegate: MulticastDelegate = .init() { @@ -156,7 +156,7 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt if shouldSkipInitialRemoteUpdate { shouldSkipInitialRemoteUpdate = false state = .remoteDataFetched - hasLoadedAllPreviousChannels = loadedChannelsCount == 0 + hasLoadedAllPreviousChannels = channels.isEmpty markChannelsAsDeliveredIfNeeded(channels: Array(channels)) callback { completion?(nil) @@ -189,11 +189,10 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt let limit = limit ?? query.pagination.pageSize var updatedQuery = query - updatedQuery.pagination = Pagination(pageSize: limit, offset: loadedChannelsCount) + updatedQuery.pagination = Pagination(pageSize: limit, offset: channels.count) worker.update(channelListQuery: updatedQuery) { result in switch result { case let .success(channels): - self.loadedChannelsCount += channels.count self.markChannelsAsDeliveredIfNeeded(channels: channels) self.hasLoadedAllPreviousChannels = channels.count < limit self.callback { completion?(nil) } @@ -217,19 +216,24 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt } ?? channels worker.prefill(channels: prefilledChannels, for: query) { [weak self] result in + guard let self else { return } switch result { case let .success(savedChannels): - self?.loadedChannelsCount = savedChannels.count - self?.shouldSkipInitialRemoteUpdate = true - self?.usesGroupedChannelsForSync = true + self.shouldSkipInitialRemoteUpdate = true + self.usesGroupedChannelsForSync = 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 - self?.callback { + 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 { + self.callback { completion?(error) } } @@ -239,8 +243,7 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt // MARK: - Internal func refreshLoadedChannels(completion: @escaping @Sendable (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 @@ -255,7 +258,6 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt switch result { case let .success(channels): self?.state = .remoteDataFetched - self?.loadedChannelsCount = channels.count self?.hasLoadedAllPreviousChannels = channels.count < limit // Mark channels as delivered if synchronization was successful diff --git a/Sources/StreamChat/Controllers/DatabaseObserver/BackgroundDatabaseObserver.swift b/Sources/StreamChat/Controllers/DatabaseObserver/BackgroundDatabaseObserver.swift index f8f980976ac..4cb7b0b8f3c 100644 --- a/Sources/StreamChat/Controllers/DatabaseObserver/BackgroundDatabaseObserver.swift +++ b/Sources/StreamChat/Controllers/DatabaseObserver/BackgroundDatabaseObserver.swift @@ -88,6 +88,23 @@ class BackgroundDatabaseObserver: @uncheck } } + /// 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/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift index d01d56825a9..3fa4a3ade30 100644 --- a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift @@ -340,6 +340,51 @@ final class ChannelListController_Tests: XCTestCase { ) } + 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(channels: prefilledChannels) { 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(channels: prefilledChannels) { 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 From 82a808be8ef006c8a6db6d8c38c9f8a091d38a3e Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 23 Apr 2026 16:12:05 +0300 Subject: [PATCH 21/31] Route grouped-channel prefill through ChannelListQuery.groupKey and persist using it - Add internal ChannelListQuery.groupKey and queryHash (groupKey ?? filter.filterHash); ChannelListQueryDTO now uses this stable identity so date-bearing filters from the grouped endpoint don't churn filterHash every second. - Rename channelListQuery(filterHash:) -> channelListQuery(_:) since every caller had the full ChannelListQuery in scope anyway. - Rename GroupedChannelsGroup.group -> groupKey for naming symmetry. - prefill(group:) sets query.groupKey before worker.prefill; drop the redundant ChatChannelListController.prefilledGroupKey in favor of query.groupKey as the single source of truth. - SyncRepository routes prefilled controllers through queryGroupedChannels and SyncGroupedChannelsOperation forwards each group back to the matching controller's prefill(group:) using query.groupKey. --- CHANGELOG.md | 2 +- .../ChannelListController.swift | 23 ++++++++-------- .../StreamChat/Database/DTOs/ChannelDTO.swift | 4 +-- .../Database/DTOs/ChannelListQueryDTO.swift | 10 +++---- .../StreamChat/Database/DatabaseSession.swift | 7 ++--- .../StreamChat/Models/GroupedChannels.swift | 5 ++++ .../StreamChat/Query/ChannelListQuery.swift | 9 +++++++ .../Repositories/SyncOperations.swift | 26 ++++++++++++++++--- .../Repositories/SyncRepository.swift | 4 +-- .../Workers/ChannelListUpdater.swift | 15 ++++++----- .../ChatChannelListController_Mock.swift | 10 +++++++ .../Database/DatabaseSession_Mock.swift | 4 +-- .../Spy/ChannelListUpdater_Spy.swift | 6 ++--- Tests/StreamChatTests/ChatClient_Tests.swift | 2 ++ .../ChannelListController_Tests.swift | 14 +++++----- .../ListDatabaseObserver+Sorting_Tests.swift | 2 +- .../Repositories/SyncRepository_Tests.swift | 20 +++++++++----- .../Workers/ChannelListUpdater_Tests.swift | 22 +++++----------- 18 files changed, 116 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53df97778e6..b180784d347 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming ### ✅ Added -- Add `ChatChannelListController.prefill(channels:completion:)` for priming controller-local channel data [#4071](https://github.com/GetStream/stream-chat-swift/pull/4071) +- 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) diff --git a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift index cabc50da511..53464a23d9a 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, @unchecked Sendable { /// 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 @@ -70,11 +70,6 @@ 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 - /// `true` once `prefill(...)` has successfully populated this controller. Stays `true` - /// for the controller's lifetime so `SyncRepository` can route its reconnect-refresh - /// through `queryGroupedChannels` instead of the standard `/channels` query. - @Atomic var usesGroupedChannelsForSync = false - @Atomic private var prefilledChannelCount: Int = 0 /// A type-erased delegate. var multicastDelegate: MulticastDelegate = .init() { @@ -208,19 +203,25 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt /// 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( - channels: [ChatChannel], + group: GroupedChannelsGroup, completion: (@Sendable (Error?) -> Void)? = nil ) { let prefilledChannels = filter.map { runtimeFilter in - channels.filter(runtimeFilter) - } ?? channels + 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(channels: prefilledChannels, for: query) { [weak self] result in + worker.prefill(group: prefilledGroup, for: query) { [weak self] result in guard let self else { return } switch result { case let .success(savedChannels): self.shouldSkipInitialRemoteUpdate = true - self.usesGroupedChannelsForSync = 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 diff --git a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift index 320905b3478..63db3a77bdf 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift @@ -438,7 +438,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) } @@ -471,7 +471,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 3163de1868b..666f0f93e05 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/DatabaseSession.swift b/Sources/StreamChat/Database/DatabaseSession.swift index 2f12f1652ca..4114c7ec79a 100644 --- a/Sources/StreamChat/Database/DatabaseSession.swift +++ b/Sources/StreamChat/Database/DatabaseSession.swift @@ -350,9 +350,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. diff --git a/Sources/StreamChat/Models/GroupedChannels.swift b/Sources/StreamChat/Models/GroupedChannels.swift index 5b85dd54645..f4bf2c6c1b4 100644 --- a/Sources/StreamChat/Models/GroupedChannels.swift +++ b/Sources/StreamChat/Models/GroupedChannels.swift @@ -18,6 +18,9 @@ public struct GroupedChannels: Equatable { /// 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] @@ -25,9 +28,11 @@ public struct GroupedChannelsGroup: Equatable { public let unreadChannels: Int public 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 { diff --git a/Sources/StreamChat/Query/ChannelListQuery.swift b/Sources/StreamChat/Query/ChannelListQuery.swift index cc8aa8c516c..741f934b460 100644 --- a/Sources/StreamChat/Query/ChannelListQuery.swift +++ b/Sources/StreamChat/Query/ChannelListQuery.swift @@ -71,6 +71,15 @@ public struct ChannelListQuery: Encodable, Sendable, LocalConvertibleSortingQuer 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 { diff --git a/Sources/StreamChat/Repositories/SyncOperations.swift b/Sources/StreamChat/Repositories/SyncOperations.swift index 07e9d25c653..9521d83d5c0 100644 --- a/Sources/StreamChat/Repositories/SyncOperations.swift +++ b/Sources/StreamChat/Repositories/SyncOperations.swift @@ -126,14 +126,34 @@ final class SyncGroupedChannelsOperation: AsyncOperation, @unchecked Sendable { let returnedChannelIds = groupedChannels.groups.values .flatMap(\.channels) .map(\.cid) - let controllerChannelIds = controllers.flatMap { $0.channels.map(\.cid) } context.synchedChannelIds.formUnion(returnedChannelIds) - context.synchedChannelIds.formUnion(controllerChannelIds) log.debug( "Synced \(returnedChannelIds.count) grouped channels across \(groupedChannels.groups.count) group(s)", subsystems: .offlineSupport ) - done(.continue) + + // 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) diff --git a/Sources/StreamChat/Repositories/SyncRepository.swift b/Sources/StreamChat/Repositories/SyncRepository.swift index 6c86087a451..1791063ee16 100644 --- a/Sources/StreamChat/Repositories/SyncRepository.swift +++ b/Sources/StreamChat/Repositories/SyncRepository.swift @@ -194,8 +194,8 @@ class SyncRepository: @unchecked Sendable { // 2. Refresh channel lists operations.append(contentsOf: activeChannelLists.allObjects.map { RefreshChannelListOperation(channelList: $0, context: context) }) let allControllers = activeChannelListControllers.allObjects - let prefilledControllers = allControllers.filter { $0.usesGroupedChannelsForSync } - let standardControllers = allControllers.filter { !$0.usesGroupedChannelsForSync } + 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(...)`) diff --git a/Sources/StreamChat/Workers/ChannelListUpdater.swift b/Sources/StreamChat/Workers/ChannelListUpdater.swift index 14385334a6f..a4c3183d4ef 100644 --- a/Sources/StreamChat/Workers/ChannelListUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelListUpdater.swift @@ -23,8 +23,7 @@ class ChannelListUpdater: Worker, @unchecked Sendable { var initialActions: (@Sendable (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() } } @@ -42,7 +41,7 @@ class ChannelListUpdater: Worker, @unchecked Sendable { } func prefill( - channels: [ChatChannel], + group: GroupedChannelsGroup, for query: ChannelListQuery, completion: (@Sendable (Result<[ChatChannel], Error>) -> Void)? = nil ) { @@ -51,7 +50,7 @@ class ChannelListUpdater: Worker, @unchecked Sendable { let queryDTO = session.saveQuery(query: query) queryDTO.channels.removeAll() - savedChannels = channels.compactMapLoggingError { channel in + 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 @@ -215,12 +214,14 @@ class ChannelListUpdater: Worker, @unchecked Sendable { let groupedUnreadChannels = payload.groups.mapValues(\.unreadChannels) try session.saveCurrentUserGroupedUnreadChannels(groupedUnreadChannels) - let groups = try payload.groups.mapValues { groupPayload in + 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() } - return GroupedChannelsGroup( + groups[name] = GroupedChannelsGroup( + groupKey: name, channels: channels, unreadChannels: groupPayload.unreadChannels ) @@ -242,7 +243,7 @@ class ChannelListUpdater: Worker, @unchecked Sendable { 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 } diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift index dafedec557d..e2d03d640e6 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, @unchecked 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 97c751fdcd1..505e2bf9e39 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift @@ -384,8 +384,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 c7cc30de1bc..71e0810341a 100644 --- a/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift +++ b/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift @@ -68,13 +68,13 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable } override func prefill( - channels: [ChatChannel], + group: GroupedChannelsGroup, for query: ChannelListQuery, completion: ((Result<[ChatChannel], Error>) -> Void)? = nil ) { _prefill_queries.mutate { $0.append(query) } - _prefill_channels.mutate { $0.append(channels) } - super.prefill(channels: channels, for: query, completion: completion) + _prefill_channels.mutate { $0.append(group.channels) } + super.prefill(group: group, for: query, completion: completion) } override func markAllRead(completion: ((Error?) -> Void)? = nil) { diff --git a/Tests/StreamChatTests/ChatClient_Tests.swift b/Tests/StreamChatTests/ChatClient_Tests.swift index a7104c74bd1..1bd616b98ba 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -327,11 +327,13 @@ final class ChatClient_Tests: XCTestCase { ) let group = GroupedChannelsGroup( + groupKey: "all", channels: [firstChannel, secondChannel, thirdChannel], unreadChannels: 0 ) XCTAssertEqual(group.unreadChannels, 2) + XCTAssertEqual(group.groupKey, "all") } func test_disconnect_flushesRequestsQueue() throws { diff --git a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift index 3fa4a3ade30..911879c3460 100644 --- a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift @@ -231,7 +231,7 @@ final class ChannelListController_Tests: XCTestCase { ] let prefillExpectation = expectation(description: "Prefill completes") - controller.prefill(channels: prefilledChannels) { error in + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) { error in XCTAssertNil(error) prefillExpectation.fulfill() } @@ -261,7 +261,7 @@ final class ChannelListController_Tests: XCTestCase { ] let prefillExpectation = expectation(description: "Prefill completes") - controller.prefill(channels: prefilledChannels) { error in + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) { error in XCTAssertNil(error) prefillExpectation.fulfill() } @@ -289,7 +289,7 @@ final class ChannelListController_Tests: XCTestCase { ] let prefillExpectation = expectation(description: "Prefill completes") - controller.prefill(channels: prefilledChannels) { error in + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) { error in XCTAssertNil(error) prefillExpectation.fulfill() } @@ -318,7 +318,7 @@ final class ChannelListController_Tests: XCTestCase { ] let prefillExpectation = expectation(description: "Prefill completes") - controller.prefill(channels: prefilledChannels) { error in + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) { error in XCTAssertNil(error) prefillExpectation.fulfill() } @@ -351,7 +351,7 @@ final class ChannelListController_Tests: XCTestCase { ] let prefillExpectation = expectation(description: "Prefill completes") - controller.prefill(channels: prefilledChannels) { error in + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) { error in XCTAssertNil(error) prefillExpectation.fulfill() } @@ -374,7 +374,7 @@ final class ChannelListController_Tests: XCTestCase { ] let prefillExpectation = expectation(description: "Prefill completes") - controller.prefill(channels: prefilledChannels) { error in + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) { error in XCTAssertNil(error) prefillExpectation.fulfill() } @@ -416,7 +416,7 @@ final class ChannelListController_Tests: XCTestCase { } let prefillExpectation = expectation(description: "Prefill completes") - controller.prefill(channels: [makePrefilledChannel(cid: replacementCid)]) { error in + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: [makePrefilledChannel(cid: replacementCid)], unreadChannels: 0)) { error in XCTAssertNil(error) prefillExpectation.fulfill() } diff --git a/Tests/StreamChatTests/Query/Sorting/ListDatabaseObserver+Sorting_Tests.swift b/Tests/StreamChatTests/Query/Sorting/ListDatabaseObserver+Sorting_Tests.swift index 997a96d69b2..5a1a18a343b 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 646ad6ab5aa..c703761a5a9 100644 --- a/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift +++ b/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift @@ -236,17 +236,21 @@ class SyncRepository_Tests: XCTestCase { cid: cid ) - let chatListController = ChatChannelListController_Mock(query: .init(filter: .exists(.cid)), client: client) + 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)] - chatListController.usesGroupedChannelsForSync = true repository.startTrackingChannelListController(chatListController) - channelListUpdater.queryGroupedChannels_result = .success(.init(groups: [:])) + 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) } @@ -261,10 +265,11 @@ class SyncRepository_Tests: XCTestCase { cid: prefilledCid ) - let prefilledController = ChatChannelListController_Mock(query: .init(filter: .exists(.cid)), client: client) + 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)] - prefilledController.usesGroupedChannelsForSync = true repository.startTrackingChannelListController(prefilledController) let standardController = ChatChannelListController_Mock(query: .init(filter: .in(.cid, values: [standardCid])), client: client) @@ -273,13 +278,16 @@ class SyncRepository_Tests: XCTestCase { standardController.refreshLoadedChannelsResult = .success(Set([standardCid])) repository.startTrackingChannelListController(standardController) - channelListUpdater.queryGroupedChannels_result = .success(.init(groups: [:])) + 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 { diff --git a/Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift b/Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift index 833b5fe1991..715de9603a0 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 })) From 0c4e36a4550eb379b9be6a67f5ee6b91e7eef2be Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 23 Apr 2026 16:30:25 +0300 Subject: [PATCH 22/31] Make inits internal --- Sources/StreamChat/Models/GroupedChannels.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/StreamChat/Models/GroupedChannels.swift b/Sources/StreamChat/Models/GroupedChannels.swift index f4bf2c6c1b4..506e058869c 100644 --- a/Sources/StreamChat/Models/GroupedChannels.swift +++ b/Sources/StreamChat/Models/GroupedChannels.swift @@ -9,7 +9,7 @@ public struct GroupedChannels: Equatable { /// The grouped channel groups returned by the backend, keyed by group name. public let groups: [String: GroupedChannelsGroup] - public init( + init( groups: [String: GroupedChannelsGroup] ) { self.groups = groups @@ -27,7 +27,7 @@ public struct GroupedChannelsGroup: Equatable { /// The total unread channel count in the group. public let unreadChannels: Int - public init( + init( groupKey: String, channels: [ChatChannel], unreadChannels: Int From 86e6109da03b44ec3ca79c6af0e5dc248c5c50b6 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Tue, 28 Apr 2026 10:17:10 +0300 Subject: [PATCH 23/31] Migration fixes --- .../Payloads/ChannelListPayload.swift | 6 +- .../StreamChat/Models/GroupedChannels.swift | 4 +- .../Repositories/SyncOperations.swift | 4 +- .../Workers/ChannelListLinker.swift | 10 ++- .../ChatChannelListController_Mock.swift | 4 +- .../Spy/ChannelListUpdater_Spy.swift | 28 +++---- .../Database/DatabaseSession_Tests.swift | 77 ------------------- 7 files changed, 29 insertions(+), 104 deletions(-) diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift index 1d0e558f3fc..87fca3779c2 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift @@ -25,7 +25,7 @@ extension ChannelListPayload: Decodable { } } -final class GroupedQueryChannelsRequestBody: Encodable { +final class GroupedQueryChannelsRequestBody: Encodable, Sendable { let limit: Int? let watch: Bool let presence: Bool @@ -37,7 +37,7 @@ final class GroupedQueryChannelsRequestBody: Encodable { } } -final class GroupedQueryChannelsPayload: Decodable { +final class GroupedQueryChannelsPayload: Decodable, Sendable { let groups: [String: GroupedQueryChannelsGroupPayload] let duration: String @@ -58,7 +58,7 @@ final class GroupedQueryChannelsPayload: Decodable { } } -final class GroupedQueryChannelsGroupPayload: Decodable { +final class GroupedQueryChannelsGroupPayload: Decodable, Sendable { let channels: [ChannelPayload] let unreadChannels: Int diff --git a/Sources/StreamChat/Models/GroupedChannels.swift b/Sources/StreamChat/Models/GroupedChannels.swift index 506e058869c..c9b6198ccbf 100644 --- a/Sources/StreamChat/Models/GroupedChannels.swift +++ b/Sources/StreamChat/Models/GroupedChannels.swift @@ -5,7 +5,7 @@ import Foundation /// A grouped channels response returned by `ChatClient.queryGroupedChannels`. -public struct GroupedChannels: Equatable { +public struct GroupedChannels: Equatable, Sendable { /// The grouped channel groups returned by the backend, keyed by group name. public let groups: [String: GroupedChannelsGroup] @@ -17,7 +17,7 @@ public struct GroupedChannels: Equatable { } /// A grouped channels group returned by `ChatClient.queryGroupedChannels`. -public struct GroupedChannelsGroup: Equatable { +public struct GroupedChannelsGroup: Equatable, Sendable { /// The group key as returned by the backend (e.g. `"all"`, `"new"`, `"current"`). public let groupKey: String diff --git a/Sources/StreamChat/Repositories/SyncOperations.swift b/Sources/StreamChat/Repositories/SyncOperations.swift index 9521d83d5c0..17a48c7cb08 100644 --- a/Sources/StreamChat/Repositories/SyncOperations.swift +++ b/Sources/StreamChat/Repositories/SyncOperations.swift @@ -141,7 +141,7 @@ final class SyncGroupedChannelsOperation: AsyncOperation, @unchecked Sendable { let group = groupedChannels.groups[key] else { continue } dispatchGroup.enter() - controller.prefill(group: group) { error in + controller.prefill(group: group) { @Sendable error in if let error { log.error( "Failed to prefill controller for group \(key): \(error)", @@ -151,7 +151,7 @@ final class SyncGroupedChannelsOperation: AsyncOperation, @unchecked Sendable { dispatchGroup.leave() } } - dispatchGroup.notify(queue: .global(qos: .utility)) { + dispatchGroup.notify(queue: .global(qos: .utility)) { @Sendable in done(.continue) } case let .failure(error): diff --git a/Sources/StreamChat/Workers/ChannelListLinker.swift b/Sources/StreamChat/Workers/ChannelListLinker.swift index a2e815a0f34..53222bd5fce 100644 --- a/Sources/StreamChat/Workers/ChannelListLinker.swift +++ b/Sources/StreamChat/Workers/ChannelListLinker.swift @@ -46,8 +46,9 @@ final class ChannelListLinker: Sendable { notificationCenter: nc, transform: { $0 as? MessageNewEvent }, callback: { [weak self] event in - self?.unlinkChannelIfNeeded(event.channel) { - self?.linkChannelIfNeeded(event.channel) + guard let self else { return } + self.unlinkChannelIfNeeded(event.channel) { + self.linkChannelIfNeeded(event.channel) } } ), @@ -55,8 +56,9 @@ final class ChannelListLinker: Sendable { notificationCenter: nc, transform: { $0 as? NotificationMessageNewEvent }, callback: { [weak self] event in - self?.unlinkChannelIfNeeded(event.channel) { - self?.linkChannelIfNeeded(event.channel) + guard let self else { return } + self.unlinkChannelIfNeeded(event.channel) { + self.linkChannelIfNeeded(event.channel) } } ), diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift index e2d03d640e6..797c90eab38 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift @@ -32,7 +32,7 @@ class ChatChannelListController_Mock: ChatChannelListController, Spy, @unchecked loadNextChannelsIsCalled = true } - override func refreshLoadedChannels(completion: @escaping (Result, any Error>) -> Void) { + override func refreshLoadedChannels(completion: @escaping @Sendable (Result, any Error>) -> Void) { record() refreshLoadedChannelsResult.map(completion) } @@ -40,7 +40,7 @@ class ChatChannelListController_Mock: ChatChannelListController, Spy, @unchecked @Atomic var prefill_groups: [GroupedChannelsGroup] = [] override func prefill( group: GroupedChannelsGroup, - completion: ((Error?) -> Void)? = nil + completion: (@Sendable (Error?) -> Void)? = nil ) { record() _prefill_groups.mutate { $0.append(group) } diff --git a/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift b/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift index 71e0810341a..ace469f0250 100644 --- a/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift +++ b/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift @@ -10,14 +10,14 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable let spyState = SpyState() @Atomic var update_queries: [ChannelListQuery] = [] - @Atomic var update_completion: ((Result<[ChatChannel], Error>) -> Void)? + @Atomic var update_completion: (@Sendable (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 fetch_completion: (@Sendable (Result) -> Void)? @Atomic var refreshLoadedChannelsResult: Result, Error>? @Atomic var refreshLoadedChannels_channelCounts: [Int] = [] @@ -25,15 +25,15 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable @Atomic var queryGroupedChannels_callCount = 0 @Atomic var queryGroupedChannels_result: Result? - @Atomic var markAllRead_completion: ((Error?) -> Void)? + @Atomic var markAllRead_completion: (@Sendable (Error?) -> Void)? var startWatchingChannels_callCount = 0 @Atomic var startWatchingChannels_cids: [ChannelId] = [] - @Atomic var startWatchingChannels_completion: ((Error?) -> Void)? + @Atomic var startWatchingChannels_completion: (@Sendable (Error?) -> Void)? var startWatchingChannels_completion_success = false var link_callCount = 0 - var link_completion: ((Error?) -> Void)? + var link_completion: (@Sendable (Error?) -> Void)? var unlink_callCount = 0 @@ -60,7 +60,7 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable override func update( channelListQuery: ChannelListQuery, - completion: ((Result<[ChatChannel], Error>) -> Void)? = nil + completion: (@Sendable (Result<[ChatChannel], Error>) -> Void)? = nil ) { _update_queries.mutate { $0.append(channelListQuery) } update_completion = completion @@ -70,29 +70,29 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable override func prefill( group: GroupedChannelsGroup, for query: ChannelListQuery, - completion: ((Result<[ChatChannel], Error>) -> Void)? = nil + completion: (@Sendable (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) { + override func markAllRead(completion: (@Sendable (Error?) -> Void)? = nil) { markAllRead_completion = completion } override func fetch( channelListQuery: ChannelListQuery, - completion: @escaping (Result) -> Void + completion: @escaping @Sendable (Result) -> Void ) { _fetch_queries.mutate { $0.append(channelListQuery) } fetch_completion = completion } - + override func refreshLoadedChannels( for query: ChannelListQuery, channelCount: Int, - completion: @escaping (Result, any Error>) -> Void + completion: @escaping @Sendable (Result, any Error>) -> Void ) { record() _refreshLoadedChannels_channelCounts.mutate { $0.append(channelCount) } @@ -118,7 +118,7 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable override func link( channel: ChatChannel, with query: ChannelListQuery, - completion: ((Error?) -> Void)? = nil + completion: (@Sendable (Error?) -> Void)? = nil ) { link_callCount += 1 link_completion = completion @@ -127,12 +127,12 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable override func unlink( channel: ChatChannel, with query: ChannelListQuery, - completion: ((Error?) -> Void)? = nil + completion: (@Sendable (Error?) -> Void)? = nil ) { unlink_callCount += 1 } - override func startWatchingChannels(withIds ids: [ChannelId], completion: ((Error?) -> Void)?) { + override func startWatchingChannels(withIds ids: [ChannelId], completion: (@Sendable (Error?) -> Void)?) { startWatchingChannels_callCount += 1 startWatchingChannels_cids = ids if startWatchingChannels_completion_success { diff --git a/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift b/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift index 8fb4116d40e..9573d292638 100644 --- a/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift +++ b/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift @@ -524,83 +524,6 @@ final class DatabaseSession_Tests: XCTestCase { XCTAssertNotNil(messageAfterEvent) } - func test_saveEvent_whenMessageDeletedEventHasPreviewMessage_updatesChannelPreview() throws { - // GIVEN - let previousMessage: MessagePayload = .dummy( - messageId: .unique, - authorUserId: .unique - ) - - let previewMessage: MessagePayload = .dummy( - messageId: .unique, - authorUserId: .unique, - createdAt: previousMessage.createdAt.addingTimeInterval(10) - ) - - let channel: ChannelPayload = .dummy( - messages: [ - previousMessage, - previewMessage - ] - ) - - try database.writeSynchronously { session in - try session.saveChannel(payload: channel) - } - - // WHEN - let messageDeletedEvent = EventPayload( - eventType: .messageDeleted, - cid: channel.channel.cid, - message: previewMessage - ) - - try database.writeSynchronously { session in - try session.saveEvent(payload: messageDeletedEvent) - } - - // THEN - let channelDTO = try XCTUnwrap(database.viewContext.channel(cid: channel.channel.cid)) - XCTAssertEqual(channelDTO.previewMessage?.id, previewMessage.id) - } - - func test_saveEvent_whenMessageNewEventComes_updatesChannelPreview() throws { - // GIVEN - let previewMessage: MessagePayload = .dummy( - messageId: .unique, - authorUserId: .unique - ) - - let channel: ChannelPayload = .dummy( - messages: [previewMessage] - ) - - try database.writeSynchronously { session in - try session.saveChannel(payload: channel) - } - - // WHEN - let newMessage: MessagePayload = .dummy( - messageId: .unique, - authorUserId: .unique, - createdAt: previewMessage.createdAt.addingTimeInterval(10) - ) - - let messageNewEvent = EventPayload( - eventType: .messageNew, - cid: channel.channel.cid, - message: newMessage - ) - - try database.writeSynchronously { session in - try session.saveEvent(payload: messageNewEvent) - } - - // THEN - let channelDTO = try XCTUnwrap(database.viewContext.channel(cid: channel.channel.cid)) - XCTAssertEqual(channelDTO.previewMessage?.id, newMessage.id) - } - func test_saveEvent_whenMessageNewEventComes_whenIsThreadReply_thenShowInsideThreadIsTrue() throws { // GIVEN let channel: ChannelPayload = .dummy( From 74a49bb98a8055154f9fe2bdcc990a9fc966967f Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Tue, 28 Apr 2026 10:29:41 +0300 Subject: [PATCH 24/31] Point default BaseURL at Dublin edge for grouped channels testing --- Sources/StreamChat/Config/BaseURL.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/StreamChat/Config/BaseURL.swift b/Sources/StreamChat/Config/BaseURL.swift index d4fc5454a19..874d35e3e6c 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, Sendable { /// 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/")! From c7967519aa417176ec1f383156058c3b1fd0d384 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Tue, 28 Apr 2026 11:04:02 +0300 Subject: [PATCH 25/31] Update CHANGELOG to reference PR #4076 --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b180784d347..ca979aa9627 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,12 @@ 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) +- Add `ChatChannelListController.prefill(group:completion:)` for priming controller-local channel data [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076) +- Add `ChatClient.queryGroupedChannels(limit:watch:presence:)` to fetch grouped channels with per group unread counts [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076) +- Add optional `groupedUnreadChannels` data to relevant web-socket events and to `CurrentChatUser` [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076) ### 🔄 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) +- Make grouped channels decoding tolerate missing `unread_count` and `unread_channels` fields in group buckets, matching the current OpenAPI schema [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076) # [5.1.0](https://github.com/GetStream/stream-chat-swift/releases/tag/5.1.0) _April 23, 2026_ From 9053022a7296ee31f0caf4515e480edf21e14a16 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Mon, 4 May 2026 10:15:53 +0200 Subject: [PATCH 26/31] Add prefill support for ChannelList --- CHANGELOG.md | 1 + .../ChannelListController.swift | 8 +- .../BackgroundDatabaseObserver.swift | 38 +++-- .../Repositories/SyncOperations.swift | 48 +++++- .../Repositories/SyncRepository.swift | 10 +- .../StreamChat/StateLayer/ChannelList.swift | 47 +++++- .../ChannelListState+Observer.swift | 8 + .../StateLayer/ChannelListState.swift | 29 +++- .../StateLayerDatabaseObserver.swift | 34 +++- .../Workers/ChannelListUpdater.swift | 8 + .../StreamChat/State/ChannelList_Mock.swift | 12 ++ .../ChannelListController_Tests.swift | 48 ++++++ .../Repositories/SyncRepository_Tests.swift | 54 +++++++ .../StateLayer/ChannelList_Tests.swift | 148 ++++++++++++++++-- 14 files changed, 442 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca979aa9627..00c526b58d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming ### ✅ Added +- Add `ChannelList.prefill(group:)` for priming state-layer channel data [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076) - Add `ChatChannelListController.prefill(group:completion:)` for priming controller-local channel data [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076) - Add `ChatClient.queryGroupedChannels(limit:watch:presence:)` to fetch grouped channels with per group unread counts [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076) - Add optional `groupedUnreadChannels` data to relevant web-socket events and to `CurrentChatUser` [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076) diff --git a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift index 53464a23d9a..f9075399ba3 100644 --- a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift +++ b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift @@ -227,9 +227,11 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt 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) - } + let request = ChannelDTO.channelListFetchRequest(query: self.query, chatClientConfig: self.client.config) + let fetchLimit = max(self.query.pagination.pageSize, prefilledChannels.count) + request.fetchLimit = fetchLimit + request.fetchBatchSize = fetchLimit + self.channelListObserver.resetFetchRequest(request) self.callback { completion?(nil) } diff --git a/Sources/StreamChat/Controllers/DatabaseObserver/BackgroundDatabaseObserver.swift b/Sources/StreamChat/Controllers/DatabaseObserver/BackgroundDatabaseObserver.swift index 4cb7b0b8f3c..70d4ac9ac5b 100644 --- a/Sources/StreamChat/Controllers/DatabaseObserver/BackgroundDatabaseObserver.swift +++ b/Sources/StreamChat/Controllers/DatabaseObserver/BackgroundDatabaseObserver.swift @@ -14,6 +14,7 @@ class BackgroundDatabaseObserver: @uncheck private let itemCreator: (DTO) throws -> Item private let itemReuseKeyPaths: (item: KeyPath, dto: KeyPath)? private let sorting: [SortValue] + private let fetchedResultsControllerType: NSFetchedResultsController.Type /// Used to observe the changes in the DB. private(set) var frc: NSFetchedResultsController @@ -73,6 +74,7 @@ class BackgroundDatabaseObserver: @uncheck self.itemCreator = itemCreator self.itemReuseKeyPaths = itemReuseKeyPaths self.sorting = sorting + self.fetchedResultsControllerType = fetchedResultsControllerType changeAggregator = ListChangeAggregator(itemCreator: itemCreator) frc = fetchedResultsControllerType.init( fetchRequest: fetchRequest, @@ -88,21 +90,39 @@ class BackgroundDatabaseObserver: @uncheck } } - /// 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 } + /// Replaces the underlying fetch request and re-fetches items while preserving delegate wiring. + @discardableResult + func resetFetchRequest(_ fetchRequest: NSFetchRequest) -> [Item] { + let context = frc.managedObjectContext + nonisolated(unsafe) var items: [Item] = [] + nonisolated(unsafe) var shouldNotifyDidChange = false + context.performAndWait { + let wasInitialized = self.isInitialized + self.frc.delegate = nil + self.frc = self.fetchedResultsControllerType.init( + fetchRequest: fetchRequest, + managedObjectContext: context, + sectionNameKeyPath: nil, + cacheName: nil + ) + if wasInitialized { + self.frc.delegate = self.changeAggregator + } + self._items = nil do { try self.frc.performFetch() } catch { - log.error("Failed to re-fetch after updating fetchLimit to \(newLimit): \(error)") + log.error("Failed to reset fetch request: \(error)") return } - self.updateItems(nil) + items = self.updateItems(nil) + shouldNotifyDidChange = wasInitialized } + if shouldNotifyDidChange { + let changes: [ListChange] = items.enumerated().map { .insert($1, index: IndexPath(item: $0, section: 0)) } + notifyDidChange(changes: changes) + } + return items } /// Starts observing the changes in the database. diff --git a/Sources/StreamChat/Repositories/SyncOperations.swift b/Sources/StreamChat/Repositories/SyncOperations.swift index 17a48c7cb08..53bf05d6472 100644 --- a/Sources/StreamChat/Repositories/SyncOperations.swift +++ b/Sources/StreamChat/Repositories/SyncOperations.swift @@ -94,14 +94,19 @@ final class RefreshChannelListOperation: AsyncOperation, @unchecked Sendable { done(.continue) return } + guard channelList.query.value.groupKey == nil else { + done(.continue) + return + } + let query = channelList.query.value Task { do { let channelIds = try await channelList.refreshLoadedChannels() - log.debug("Synced \(channelIds.count) channels in a channel list (\(channelList.query.filter)", subsystems: .offlineSupport) + log.debug("Synced \(channelIds.count) channels in a channel list (\(query.filter)", subsystems: .offlineSupport) context.synchedChannelIds.formUnion(channelIds) done(.continue) } catch { - log.error("Failed refreshing channel list (\(channelList.query.filter) with error \(error)", subsystems: .offlineSupport) + log.error("Failed refreshing channel list (\(query.filter) with error \(error)", subsystems: .offlineSupport) done(.retry) } } @@ -113,16 +118,28 @@ final class SyncGroupedChannelsOperation: AsyncOperation, @unchecked Sendable { init( channelListUpdater: ChannelListUpdater, controllers: [ChatChannelListController], + channelLists: [ChannelList], context: SyncContext ) { super.init(maxRetries: syncOperationsMaximumRetries) { [weak channelListUpdater] _, done in + let channelListsAndKeys = channelLists.compactMap { channelList -> (channelList: ChannelList, groupKey: String)? in + guard let groupKey = channelList.query.value.groupKey else { return nil } + return (channelList, groupKey) + } + + guard !controllers.isEmpty || !channelListsAndKeys.isEmpty else { + done(.continue) + return + } + guard let channelListUpdater else { done(.continue) return } - channelListUpdater.queryGroupedChannels { result in - switch result { - case let .success(groupedChannels): + + Task { + do { + let groupedChannels = try await channelListUpdater.queryGroupedChannels() let returnedChannelIds = groupedChannels.groups.values .flatMap(\.channels) .map(\.cid) @@ -132,8 +149,8 @@ final class SyncGroupedChannelsOperation: AsyncOperation, @unchecked Sendable { subsystems: .offlineSupport ) - // Forward each returned group to the matching prefilled controller so the - // controller's local query-DTO links and observer state get refreshed. + // Forward each returned group to matching prefilled lists so local query-DTO + // links and observer state get refreshed. let dispatchGroup = DispatchGroup() for controller in controllers { guard @@ -151,10 +168,25 @@ final class SyncGroupedChannelsOperation: AsyncOperation, @unchecked Sendable { dispatchGroup.leave() } } + for (channelList, key) in channelListsAndKeys { + guard let group = groupedChannels.groups[key] else { continue } + dispatchGroup.enter() + Task { + do { + try await channelList.prefill(group: group) + } catch { + log.error( + "Failed to prefill channel list for group \(key): \(error)", + subsystems: .offlineSupport + ) + } + dispatchGroup.leave() + } + } dispatchGroup.notify(queue: .global(qos: .utility)) { @Sendable in done(.continue) } - case let .failure(error): + } catch { log.error("Failed to refresh grouped channels during sync: \(error)", subsystems: .offlineSupport) done(.retry) } diff --git a/Sources/StreamChat/Repositories/SyncRepository.swift b/Sources/StreamChat/Repositories/SyncRepository.swift index 1791063ee16..3bc96f257c0 100644 --- a/Sources/StreamChat/Repositories/SyncRepository.swift +++ b/Sources/StreamChat/Repositories/SyncRepository.swift @@ -192,17 +192,21 @@ class SyncRepository: @unchecked Sendable { operations.append(ActiveChannelIdsOperation(syncRepository: self, context: context)) // 2. Refresh channel lists - operations.append(contentsOf: activeChannelLists.allObjects.map { RefreshChannelListOperation(channelList: $0, context: context) }) + let allChannelLists = activeChannelLists.allObjects + let prefilledChannelLists = allChannelLists.filter { $0.query.value.groupKey != nil } + let standardChannelLists = allChannelLists.filter { $0.query.value.groupKey == nil } + operations.append(contentsOf: standardChannelLists.map { RefreshChannelListOperation(channelList: $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 { + // 2.5 Refresh grouped channels (for lists populated via `prefill(...)`) + if !prefilledControllers.isEmpty || !prefilledChannelLists.isEmpty { operations.append(SyncGroupedChannelsOperation( channelListUpdater: channelListUpdater, controllers: prefilledControllers, + channelLists: prefilledChannelLists, context: context )) } diff --git a/Sources/StreamChat/StateLayer/ChannelList.swift b/Sources/StreamChat/StateLayer/ChannelList.swift index 7583c882fb8..6d0a60df92e 100644 --- a/Sources/StreamChat/StateLayer/ChannelList.swift +++ b/Sources/StreamChat/StateLayer/ChannelList.swift @@ -8,8 +8,9 @@ import Foundation public class ChannelList: @unchecked Sendable { private let channelListUpdater: ChannelListUpdater private let client: ChatClient + private let dynamicFilter: (@Sendable (ChatChannel) -> Bool)? + let query: AllocatedUnfairLock @MainActor private var stateBuilder: StateBuilder - let query: ChannelListQuery init( query: ChannelListQuery, @@ -18,7 +19,8 @@ public class ChannelList: @unchecked Sendable { environment: Environment = .init() ) { self.client = client - self.query = query + self.dynamicFilter = dynamicFilter + self.query = AllocatedUnfairLock(query) let channelListUpdater = environment.channelListUpdater( client.databaseContainer, client.apiClient @@ -48,10 +50,37 @@ public class ChannelList: @unchecked Sendable { /// /// - Throws: An error while communicating with the Stream API. public func get() async throws { - let pagination = Pagination(pageSize: query.pagination.pageSize) + if await state.consumeShouldSkipInitialRemoteUpdate() { + return + } + let pagination = Pagination(pageSize: query.value.pagination.pageSize) try await loadChannels(with: pagination) client.syncRepository.startTrackingChannelList(self) } + + /// Prefills the channel list with an initial channel list snapshot and skips the first remote + /// `queryChannels` request when ``get()`` is called afterwards. + /// + /// The prefetched channels are persisted in the local storage and linked only to this channel + /// list query, so pagination, local observation and offline refresh keep working. + public func prefill(group: GroupedChannelsGroup) async throws { + let prefilledChannels = dynamicFilter.map { runtimeFilter in + group.channels.filter(runtimeFilter) + } ?? group.channels + let prefilledGroup = GroupedChannelsGroup( + groupKey: group.groupKey, + channels: prefilledChannels, + unreadChannels: group.unreadChannels + ) + let updatedQuery = query.withLock { + $0.groupKey = group.groupKey + return $0 + } + + _ = try await channelListUpdater.prefill(group: prefilledGroup, for: updatedQuery) + await resetStateAfterPrefill(query: updatedQuery, prefilledChannelsCount: prefilledChannels.count) + client.syncRepository.startTrackingChannelList(self) + } // MARK: - Channel List Pagination @@ -64,7 +93,7 @@ public class ChannelList: @unchecked Sendable { /// - Throws: An error while communicating with the Stream API. /// - Returns: An array of channels for the pagination. @discardableResult public func loadChannels(with pagination: Pagination) async throws -> [ChatChannel] { - try await channelListUpdater.loadChannels(query: query, pagination: pagination) + return try await channelListUpdater.loadChannels(query: query.value, pagination: pagination) } /// Loads more channels and updates ``ChannelListState/channels``. @@ -74,6 +103,7 @@ public class ChannelList: @unchecked Sendable { /// - Throws: An error while communicating with the Stream API. /// - Returns: An array of loaded channels. @discardableResult public func loadMoreChannels(limit: Int? = nil) async throws -> [ChatChannel] { + let query = query.value let limit = limit ?? query.pagination.pageSize let count = await state.channels.count return try await channelListUpdater.loadNextChannels( @@ -86,9 +116,18 @@ public class ChannelList: @unchecked Sendable { // MARK: - Internal func refreshLoadedChannels() async throws -> Set { + let query = query.value let count = await state.channels.count return try await channelListUpdater.refreshLoadedChannels(for: query, channelCount: count) } + + @MainActor private func resetStateAfterPrefill(query: ChannelListQuery, prefilledChannelsCount: Int) { + state.reset( + query: query, + minimumFetchLimit: prefilledChannelsCount + ) + state.skipNextInitialRemoteUpdate() + } } extension ChannelList { diff --git a/Sources/StreamChat/StateLayer/ChannelListState+Observer.swift b/Sources/StreamChat/StateLayer/ChannelListState+Observer.swift index f02c61fc9c9..76379a27962 100644 --- a/Sources/StreamChat/StateLayer/ChannelListState+Observer.swift +++ b/Sources/StreamChat/StateLayer/ChannelListState+Observer.swift @@ -64,5 +64,13 @@ extension ChannelListState { return [] } } + + func resetFetchRequest(query: ChannelListQuery, minimumFetchLimit: Int) -> [ChatChannel] { + let request = ChannelDTO.channelListFetchRequest(query: query, chatClientConfig: clientConfig) + let fetchLimit = max(query.pagination.pageSize, minimumFetchLimit) + request.fetchLimit = fetchLimit + request.fetchBatchSize = fetchLimit + return channelListObserver.resetFetchRequest(request) + } } } diff --git a/Sources/StreamChat/StateLayer/ChannelListState.swift b/Sources/StreamChat/StateLayer/ChannelListState.swift index 5d1dd4e917d..bf3797b1ad1 100644 --- a/Sources/StreamChat/StateLayer/ChannelListState.swift +++ b/Sources/StreamChat/StateLayer/ChannelListState.swift @@ -7,7 +7,11 @@ import Foundation /// Represents a list of channels matching to the specified query. @MainActor public final class ChannelListState: ObservableObject { - private let observer: Observer + private var observer: Observer + private var shouldSkipInitialRemoteUpdate = false + private var handlers: Observer.Handlers { + .init(channelsDidChange: { [weak self] in self?.channels = $0 }) + } init( query: ChannelListQuery, @@ -28,14 +32,29 @@ import Foundation eventNotificationCenter: eventNotificationCenter, channelWatcherHandler: channelWatcherHandler ) - channels = observer.start( - with: .init(channelsDidChange: { [weak self] in self?.channels = $0 }) - ) + channels = observer.start(with: handlers) } /// The query used for filtering the list of channels. - public let query: ChannelListQuery + public private(set) var query: ChannelListQuery /// An array of channels for the specified ``ChannelListQuery``. @Published public internal(set) var channels: [ChatChannel] = [] + + func skipNextInitialRemoteUpdate() { + shouldSkipInitialRemoteUpdate = true + } + + func consumeShouldSkipInitialRemoteUpdate() -> Bool { + defer { shouldSkipInitialRemoteUpdate = false } + return shouldSkipInitialRemoteUpdate + } + + func reset( + query: ChannelListQuery, + minimumFetchLimit: Int + ) { + self.query = query + channels = observer.resetFetchRequest(query: query, minimumFetchLimit: minimumFetchLimit) + } } diff --git a/Sources/StreamChat/StateLayer/DatabaseObserver/StateLayerDatabaseObserver.swift b/Sources/StreamChat/StateLayer/DatabaseObserver/StateLayerDatabaseObserver.swift index 48052b4dbef..ed3da8d0a21 100644 --- a/Sources/StreamChat/StateLayer/DatabaseObserver/StateLayerDatabaseObserver.swift +++ b/Sources/StreamChat/StateLayer/DatabaseObserver/StateLayerDatabaseObserver.swift @@ -18,14 +18,15 @@ class ListResult: DatabaseObserverType {} /// - Note: Requires the ``DatabaseContainer/stateLayerContext`` which is immediately synchronized. final class StateLayerDatabaseObserver: @unchecked Sendable { private let changeAggregator: ListChangeAggregator - private let frc: NSFetchedResultsController + private var frc: NSFetchedResultsController let itemCreator: (DTO) throws -> Item let itemReuseKeyPaths: (item: KeyPath, dto: KeyPath)? let sorting: [SortValue] - let request: NSFetchRequest + var request: NSFetchRequest let context: NSManagedObjectContext // Keep track of last items for reuse private var reuseItems: [Item]? + private var isObserving = false private init( context: NSManagedObjectContext, @@ -188,10 +189,39 @@ extension StateLayerDatabaseObserver where ResultType == ListResult { let items = self.updateItems(changes) onContextDidChange(items, changes) } + isObserving = true frc.delegate = changeAggregator try frc.performFetch() return items } + + /// Replaces the underlying fetch request and returns the refreshed items. + @discardableResult func resetFetchRequest(_ fetchRequest: NSFetchRequest) -> [Item] { + nonisolated(unsafe) var collection: [Item] = [] + context.performAndWait { + self.request = fetchRequest + self.frc.delegate = nil + self.frc = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: self.context, + sectionNameKeyPath: nil, + cacheName: nil + ) + if self.isObserving { + self.frc.delegate = self.changeAggregator + } + self.reuseItems = nil + do { + try self.frc.performFetch() + } catch { + log.error("Failed to reset fetch request: \(error)") + collection = [] + return + } + collection = self.updateItems(nil) + } + return collection + } private func updateItems(_ changes: [ListChange]?) -> [Item] { let items = DatabaseItemConverter.convert( diff --git a/Sources/StreamChat/Workers/ChannelListUpdater.swift b/Sources/StreamChat/Workers/ChannelListUpdater.swift index a4c3183d4ef..187a1fc969b 100644 --- a/Sources/StreamChat/Workers/ChannelListUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelListUpdater.swift @@ -280,6 +280,14 @@ private extension ChannelListUpdater { } extension ChannelListUpdater { + @discardableResult func prefill(group: GroupedChannelsGroup, for query: ChannelListQuery) async throws -> [ChatChannel] { + try await withCheckedThrowingContinuation { continuation in + prefill(group: group, for: query) { result in + continuation.resume(with: result) + } + } + } + @discardableResult func update(channelListQuery: ChannelListQuery) async throws -> [ChatChannel] { try await withCheckedThrowingContinuation { continuation in update(channelListQuery: channelListQuery) { result in diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/State/ChannelList_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/State/ChannelList_Mock.swift index 64203337cfd..abb978c9a48 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/State/ChannelList_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/State/ChannelList_Mock.swift @@ -33,6 +33,18 @@ public class ChannelList_Mock: ChannelList, @unchecked Sendable { @MainActor public func simulate(channels: [ChatChannel]) async throws { state.channels = channels } + + @Atomic public var prefillGroups: [GroupedChannelsGroup] = [] + override public func prefill(group: GroupedChannelsGroup) async throws { + _prefillGroups.mutate { $0.append(group) } + } + + @Atomic public var refreshLoadedChannelsCallCount = 0 + @Atomic public var refreshLoadedChannelsResult: Result, Error> = .success([]) + override public func refreshLoadedChannels() async throws -> Set { + _refreshLoadedChannelsCallCount.mutate { $0 += 1 } + return try refreshLoadedChannelsResult.get() + } public var loadNextChannelsIsCalled = false override public func loadMoreChannels(limit: Int? = nil) async throws -> [ChatChannel] { diff --git a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift index 911879c3460..afcfea2b496 100644 --- a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift @@ -385,6 +385,54 @@ final class ChannelListController_Tests: XCTestCase { AssertAsync.willBeEqual(controller.channels.count, prefilledChannels.count) } + func test_prefill_whenChannelsAccessedBeforePrefillAndPrefilledCountIsBelowPageSize_observerReflectsPrefilledChannels() { + query = .init(filter: .in(.members, values: [memberId]), pageSize: 10) + controller = ChatChannelListController(query: query, client: client, environment: env.environment) + XCTAssertEqual(controller.channels.count, 0) + + 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() + + XCTAssertTrue(env.channelListUpdater?.update_queries.isEmpty ?? false) + AssertAsync.willBeEqual(Set(controller.channels.map(\.cid)), Set(prefilledChannels.map(\.cid))) + } + + func test_prefill_whenChannelsAccessedBeforePrefillAndPrefilledCountExceedsPageSize_observerReflectsAllPrefilledChannels() { + query = .init(filter: .in(.members, values: [memberId]), pageSize: 2) + controller = ChatChannelListController(query: query, client: client, environment: env.environment) + XCTAssertEqual(controller.channels.count, 0) + + 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() + + XCTAssertTrue(env.channelListUpdater?.update_queries.isEmpty ?? false) + AssertAsync.willBeEqual(Set(controller.channels.map(\.cid)), Set(prefilledChannels.map(\.cid))) + } + func test_prefill_replacesOnlyCurrentQueryLinks() throws { let sharedCid = ChannelId.unique let currentOnlyCid = ChannelId.unique diff --git a/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift b/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift index c703761a5a9..2d6b6fb1334 100644 --- a/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift +++ b/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift @@ -311,6 +311,60 @@ class SyncRepository_Tests: XCTestCase { XCTAssertCall("refreshLoadedChannels(completion:)", on: chatListController, times: 1) } + func test_syncLocalState_prefilledChannelList_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 channelList = ChannelList_Mock.mock(query: prefilledQuery, client: client) + repository.startTrackingChannelList(channelList) + 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) + XCTAssertEqual(channelList.refreshLoadedChannelsCallCount, 0) + XCTAssertEqual(channelList.prefillGroups.map(\.groupKey), ["all"]) + } + + func test_syncLocalState_mixedChannelLists_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 prefilledChannelList = ChannelList_Mock.mock(query: prefilledQuery, client: client) + repository.startTrackingChannelList(prefilledChannelList) + + let standardChannelList = ChannelList_Mock.mock(query: .init(filter: .in(.cid, values: [standardCid])), client: client) + standardChannelList.refreshLoadedChannelsResult = .success(Set([standardCid])) + repository.startTrackingChannelList(standardChannelList) + + 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) + XCTAssertEqual(prefilledChannelList.refreshLoadedChannelsCallCount, 0) + XCTAssertEqual(standardChannelList.refreshLoadedChannelsCallCount, 1) + XCTAssertEqual(prefilledChannelList.prefillGroups.map(\.groupKey), ["current"]) + XCTAssertTrue(standardChannelList.prefillGroups.isEmpty) + } + func test_syncLocalState_ignoresTheCooldown() throws { let lastSyncDate = Date() let cid = ChannelId.unique diff --git a/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift b/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift index 4f7f6102816..22138b02ee7 100644 --- a/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift +++ b/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift @@ -31,8 +31,9 @@ final class ChannelList_Tests: XCTestCase { func test_restoringState_whenDatabaseHasEntries_thenStateIsUpdated() async throws { let channelListPayload = makeMatchingChannelListPayload(channelCount: 5, createdAtOffset: 0) + let query = await channelList.state.query try await env.client.mockDatabaseContainer.write { session in - session.saveChannelList(payload: channelListPayload, query: self.channelList.query) + session.saveChannelList(payload: channelListPayload, query: query) } await setUpChannelList(usesMockedChannelUpdater: true) XCTAssertEqual(channelListPayload.channels.map(\.channel.cid.rawValue), await channelList.state.channels.map(\.cid.rawValue)) @@ -41,11 +42,12 @@ final class ChannelList_Tests: XCTestCase { func test_restoringState_whenDatabaseHasEntriesWhichShouldBeIgnored_thenStateOnlyIncludesQueryMatchingResults() async throws { let matchingChannelListPayload = makeMatchingChannelListPayload(channelCount: 5, createdAtOffset: 0) let deletedChannelPayload = makeMatchingChannelPayload(createdAtOffset: 5) + let query = await channelList.state.query try await env.client.mockDatabaseContainer.write { session in // These match with the query - session.saveChannelList(payload: matchingChannelListPayload, query: self.channelList.query) + session.saveChannelList(payload: matchingChannelListPayload, query: query) // Should be ignored because it was deleted - let dto = try session.saveChannel(payload: deletedChannelPayload, query: self.channelList.query, cache: nil) + let dto = try session.saveChannel(payload: deletedChannelPayload, query: query, cache: nil) dto.deletedAt = .unique // Unrelated channel to the query try session.saveChannel(payload: self.dummyPayload(with: .unique)) @@ -59,8 +61,9 @@ final class ChannelList_Tests: XCTestCase { func test_get_whenLocalStoreHasChannels_thenGetResetsChannels() async throws { // Existing state let channelListPayload = makeMatchingChannelListPayload(channelCount: 10, createdAtOffset: 0) + let query = await channelList.state.query try await env.client.mockDatabaseContainer.write { session in - session.saveChannelList(payload: channelListPayload, query: self.channelList.query) + session.saveChannelList(payload: channelListPayload, query: query) } await setUpChannelList(usesMockedChannelUpdater: false) @@ -86,6 +89,96 @@ final class ChannelList_Tests: XCTestCase { await XCTAssertEqual(nextChannelListPayload.channels.map(\.channel.cid.rawValue), channelList.state.channels.map(\.cid.rawValue)) } + // MARK: - Prefill + + func test_prefill_skipsInitialGetRequest() async throws { + await setUpChannelList(usesMockedChannelUpdater: false, loadState: false, pageSize: 2) + let prefilledChannels = try await makePrefilledChannels(count: 2, createdAtOffset: 0) + + try await channelList.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) + try await channelList.get() + + XCTAssertTrue(env.client.mockAPIClient.request_allRecordedCalls.isEmpty) + await XCTAssertEqual("all", channelList.state.query.groupKey) + await XCTAssertEqual(prefilledChannels.map(\.cid.rawValue), channelList.state.channels.map(\.cid.rawValue)) + } + + func test_prefill_loadMoreChannels_usesPrefilledChannelsCountAsOffset() async throws { + await setUpChannelList(usesMockedChannelUpdater: true, loadState: false, pageSize: 2) + let prefilledChannels = try await makePrefilledChannels(count: 3, createdAtOffset: 0) + env.channelListUpdaterMock.update_completion_result = .success([]) + + try await channelList.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) + _ = try await channelList.loadMoreChannels(limit: 2) + + XCTAssertEqual(env.channelListUpdaterMock.update_queries.last?.pagination, .init(pageSize: 2, offset: 3)) + } + + func test_prefill_whenPrefilledCountExceedsPageSize_stateExposesAllPrefilledChannels() async throws { + await setUpChannelList(usesMockedChannelUpdater: false, loadState: false, pageSize: 2) + let prefilledChannels = try await makePrefilledChannels(count: 3, createdAtOffset: 0) + + try await channelList.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) + + await XCTAssertEqual(prefilledChannels.map(\.cid.rawValue), channelList.state.channels.map(\.cid.rawValue)) + } + + func test_prefill_whenStateAccessedBeforePrefillAndPrefilledCountIsBelowPageSize_stateExposesPrefilledChannels() async throws { + await setUpChannelList(usesMockedChannelUpdater: false, pageSize: 10) + await XCTAssertEqual(0, channelList.state.channels.count) + let prefilledChannels = try await makePrefilledChannels(count: 3, createdAtOffset: 0) + + try await channelList.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) + try await channelList.get() + + XCTAssertTrue(env.client.mockAPIClient.request_allRecordedCalls.isEmpty) + await XCTAssertEqual(prefilledChannels.map(\.cid.rawValue), channelList.state.channels.map(\.cid.rawValue)) + } + + func test_prefill_whenStateAccessedBeforePrefillAndPrefilledCountExceedsPageSize_stateExposesAllPrefilledChannels() async throws { + await setUpChannelList(usesMockedChannelUpdater: false, pageSize: 2) + await XCTAssertEqual(0, channelList.state.channels.count) + let prefilledChannels = try await makePrefilledChannels(count: 3, createdAtOffset: 0) + + try await channelList.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) + try await channelList.get() + + XCTAssertTrue(env.client.mockAPIClient.request_allRecordedCalls.isEmpty) + await XCTAssertEqual(prefilledChannels.map(\.cid.rawValue), channelList.state.channels.map(\.cid.rawValue)) + } + + func test_prefill_replacesOnlyCurrentQueryLinks() async throws { + await setUpChannelList(usesMockedChannelUpdater: false) + let sharedPayload = makeMatchingChannelPayload(createdAtOffset: 0) + let currentOnlyPayload = makeMatchingChannelPayload(createdAtOffset: 1) + let replacementPayload = makeMatchingChannelPayload(createdAtOffset: 2) + let otherQuery = ChannelListQuery(filter: .equal(.cid, to: sharedPayload.channel.cid)) + var prefilledQuery = await channelList.state.query + prefilledQuery.groupKey = "all" + try await env.client.mockDatabaseContainer.write { session in + _ = session.saveChannelList(payload: .init(channels: [sharedPayload, currentOnlyPayload]), query: prefilledQuery) + _ = session.saveChannelList(payload: .init(channels: [sharedPayload]), query: otherQuery) + try session.saveChannel(payload: replacementPayload) + } + let replacementChannel = try env.client.databaseContainer.readSynchronously { session in + try XCTUnwrap(session.channel(cid: replacementPayload.channel.cid)).asModel() + } + let prefilledGroup = GroupedChannelsGroup( + groupKey: "all", + channels: [replacementChannel], + unreadChannels: 0 + ) + + try await channelList.prefill(group: prefilledGroup) + + try env.client.databaseContainer.readAndWait { session in + let currentQueryDTO = try XCTUnwrap(session.channelListQuery(prefilledQuery)) + let otherQueryDTO = try XCTUnwrap(session.channelListQuery(otherQuery)) + XCTAssertEqual(Set([replacementPayload.channel.cid.rawValue]), Set(currentQueryDTO.channels.map(\.cid))) + XCTAssertEqual(Set([sharedPayload.channel.cid.rawValue]), Set(otherQueryDTO.channels.map(\.cid))) + } + } + // MARK: - Pagination and Channel Updater Arguments func test_loadChannels_whenChannelUpdaterSucceeds_thenLoadSucceeds() async throws { @@ -95,10 +188,11 @@ final class ChannelList_Tests: XCTestCase { let pagination = Pagination(pageSize: pageSize, offset: 0) let result = try await channelList.loadChannels(with: pagination) + let query = await channelList.state.query XCTAssertEqual(env.channelListUpdaterMock.update_queries.count, 1) - XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.filter, channelList.query.filter) - XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.sort, channelList.query.sort) + XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.filter, query.filter) + XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.sort, query.sort) XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.pagination.pageSize, pageSize) XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.pagination.offset, 0) XCTAssertEqual(responseChannels, result) @@ -115,10 +209,11 @@ final class ChannelList_Tests: XCTestCase { let responseChannels = makeChannels(count: pageSize, createdAtOffset: 0) env.channelListUpdaterMock.update_completion_result = .success(responseChannels) let result = try await channelList.loadMoreChannels(limit: pageSize) + let query = await channelList.state.query XCTAssertEqual(env.channelListUpdaterMock.update_queries.count, 1) - XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.filter, channelList.query.filter) - XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.sort, channelList.query.sort) + XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.filter, query.filter) + XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.sort, query.sort) XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.pagination.pageSize, pageSize) XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.pagination.offset, 0) XCTAssertEqual(responseChannels, result) @@ -146,8 +241,9 @@ final class ChannelList_Tests: XCTestCase { func test_loadMoreChannels_whenAPIRequestSucceeds_thenStateUpdates() async throws { // Initial DB state let existingChannelListPayload = makeMatchingChannelListPayload(channelCount: 2, createdAtOffset: 0) + let query = await channelList.state.query try await env.client.mockDatabaseContainer.write { session in - session.saveChannelList(payload: existingChannelListPayload, query: self.channelList.query) + session.saveChannelList(payload: existingChannelListPayload, query: query) } await setUpChannelList(usesMockedChannelUpdater: false) @@ -234,8 +330,9 @@ final class ChannelList_Tests: XCTestCase { XCTAssertEqual(incomingChannelListPayload.channels.map(\.channel.cid.rawValue), channels.map(\.cid.rawValue)) expectation.fulfill() } + let query = await channelList.state.query try await env.client.mockDatabaseContainer.write { session in - session.saveChannelList(payload: incomingChannelListPayload, query: self.channelList.query) + session.saveChannelList(payload: incomingChannelListPayload, query: query) } await fulfillment(of: [expectation], timeout: defaultTimeout) cancellable.cancel() @@ -263,8 +360,9 @@ final class ChannelList_Tests: XCTestCase { XCTAssertTrue(channels.allSatisfy(\.isPinned), channels.filter { !$0.isPinned }.map(\.cid.rawValue).joined()) expectation.fulfill() } + let query = await channelList.state.query try await env.client.mockDatabaseContainer.write { session in - session.saveChannelList(payload: incomingChannelListPayload, query: self.channelList.query) + session.saveChannelList(payload: incomingChannelListPayload, query: query) } await fulfillment(of: [expectation], timeout: defaultTimeout) cancellable.cancel() @@ -300,8 +398,9 @@ final class ChannelList_Tests: XCTestCase { XCTAssertEqual([true, true, false, false, false], channels.map(\.isPinned)) expectation.fulfill() } + let query = await channelList.state.query try await env.client.mockDatabaseContainer.write { session in - session.saveChannelList(payload: incomingChannelListPayload, query: self.channelList.query) + session.saveChannelList(payload: incomingChannelListPayload, query: query) } await fulfillment(of: [expectation], timeout: defaultTimeout) cancellable.cancel() @@ -336,8 +435,9 @@ final class ChannelList_Tests: XCTestCase { XCTAssertTrue(channels.allSatisfy(\.isArchived), channels.filter { !$0.isArchived }.map(\.cid.rawValue).joined()) expectation.fulfill() } + let query = await channelList.state.query try await env.client.mockDatabaseContainer.write { session in - session.saveChannelList(payload: incomingChannelListPayload, query: self.channelList.query) + session.saveChannelList(payload: incomingChannelListPayload, query: query) } await fulfillment(of: [expectation], timeout: defaultTimeout) cancellable.cancel() @@ -409,8 +509,9 @@ final class ChannelList_Tests: XCTestCase { await setUpChannelList(usesMockedChannelUpdater: false, dynamicFilter: { _ in true }) // Create channel list let existingChannelListPayload = makeMatchingChannelListPayload(channelCount: 1, createdAtOffset: 0) + let query = await channelList.state.query try await env.client.mockDatabaseContainer.write { session in - session.saveChannelList(payload: existingChannelListPayload, query: self.channelList.query) + session.saveChannelList(payload: existingChannelListPayload, query: query) } // New channel event @@ -450,8 +551,9 @@ final class ChannelList_Tests: XCTestCase { // Create channel list let existingChannelListPayload = makeMatchingChannelListPayload(channelCount: 1, createdAtOffset: 0) let existingCid = try XCTUnwrap(existingChannelListPayload.channels.first?.channel.cid) + let query = await channelList.state.query try await env.client.mockDatabaseContainer.write { session in - session.saveChannelList(payload: existingChannelListPayload, query: self.channelList.query) + session.saveChannelList(payload: existingChannelListPayload, query: query) } // Ensure that the channel is in the state XCTAssertEqual(existingChannelListPayload.channels.map(\.channel.cid.rawValue), await channelList.state.channels.map(\.cid.rawValue)) @@ -483,8 +585,9 @@ final class ChannelList_Tests: XCTestCase { let pageCount = 2 let loadedCount = pageCount * Int.channelsPageSize let existingChannelListPayload = makeMatchingChannelListPayload(channelCount: loadedCount, createdAtOffset: 0) + let query = await channelList.state.query try await env.client.mockDatabaseContainer.write { session in - session.saveChannelList(payload: existingChannelListPayload, query: self.channelList.query) + session.saveChannelList(payload: existingChannelListPayload, query: query) } // Ensure that the channel is in the state @@ -514,13 +617,15 @@ final class ChannelList_Tests: XCTestCase { usesMockedChannelUpdater: Bool, loadState: Bool = true, filter: Filter? = nil, + pageSize: Int = .channelsPageSize, sort: [Sorting] = [.init(key: .createdAt, isAscending: true)], dynamicFilter: (@Sendable (ChatChannel) -> Bool)? = nil ) { channelList = ChannelList( query: ChannelListQuery( filter: filter ?? .in(.members, values: [memberId]), - sort: sort + sort: sort, + pageSize: pageSize ), dynamicFilter: dynamicFilter, client: env.client, @@ -537,6 +642,15 @@ final class ChannelList_Tests: XCTestCase { .sorted(by: { $0.cid.rawValue < $1.cid.rawValue }) } + private func makePrefilledChannels(count: Int, createdAtOffset: Int) async throws -> [ChatChannel] { + let payload = makeMatchingChannelListPayload(channelCount: count, createdAtOffset: createdAtOffset) + nonisolated(unsafe) var channels: [ChatChannel] = [] + try await env.client.mockDatabaseContainer.write { session in + channels = try payload.channels.map { try session.saveChannel(payload: $0).asModel() } + } + return channels + } + private func makeMatchingChannelPayload(createdAtOffset: Int) -> ChannelPayload { makeMatchingChannelListPayload(channelCount: 1, createdAtOffset: createdAtOffset).channels[0] } From ff593a15dd93706a4998e5910f7fe11b47e17cd9 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Mon, 4 May 2026 11:27:03 +0200 Subject: [PATCH 27/31] Tidy prefill handling --- .../ChannelListController.swift | 12 ++-------- .../StreamChat/StateLayer/ChannelList.swift | 22 +++++++++---------- .../StateLayer/ChannelListState.swift | 3 +++ .../Workers/ChannelListUpdater.swift | 12 +++++++--- .../Spy/ChannelListUpdater_Spy.swift | 3 ++- .../StateLayer/ChannelList_Tests.swift | 10 +++++++++ 6 files changed, 37 insertions(+), 25 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift index f9075399ba3..f3856578705 100644 --- a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift +++ b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift @@ -206,18 +206,10 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt group: GroupedChannelsGroup, completion: (@Sendable (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 + worker.prefill(group: group, for: query, filter: filter) { [weak self] result in guard let self else { return } switch result { case let .success(savedChannels): @@ -228,7 +220,7 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt // 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 let request = ChannelDTO.channelListFetchRequest(query: self.query, chatClientConfig: self.client.config) - let fetchLimit = max(self.query.pagination.pageSize, prefilledChannels.count) + let fetchLimit = max(self.query.pagination.pageSize, savedChannels.count) request.fetchLimit = fetchLimit request.fetchBatchSize = fetchLimit self.channelListObserver.resetFetchRequest(request) diff --git a/Sources/StreamChat/StateLayer/ChannelList.swift b/Sources/StreamChat/StateLayer/ChannelList.swift index 6d0a60df92e..89a24b44a2a 100644 --- a/Sources/StreamChat/StateLayer/ChannelList.swift +++ b/Sources/StreamChat/StateLayer/ChannelList.swift @@ -64,21 +64,13 @@ public class ChannelList: @unchecked Sendable { /// The prefetched channels are persisted in the local storage and linked only to this channel /// list query, so pagination, local observation and offline refresh keep working. public func prefill(group: GroupedChannelsGroup) async throws { - let prefilledChannels = dynamicFilter.map { runtimeFilter in - group.channels.filter(runtimeFilter) - } ?? group.channels - let prefilledGroup = GroupedChannelsGroup( - groupKey: group.groupKey, - channels: prefilledChannels, - unreadChannels: group.unreadChannels - ) let updatedQuery = query.withLock { $0.groupKey = group.groupKey return $0 } - _ = try await channelListUpdater.prefill(group: prefilledGroup, for: updatedQuery) - await resetStateAfterPrefill(query: updatedQuery, prefilledChannelsCount: prefilledChannels.count) + let savedChannels = try await channelListUpdater.prefill(group: group, for: updatedQuery, filter: dynamicFilter) + await resetStateAfterPrefill(query: updatedQuery, prefilledChannelsCount: savedChannels.count) client.syncRepository.startTrackingChannelList(self) } @@ -103,14 +95,17 @@ public class ChannelList: @unchecked Sendable { /// - Throws: An error while communicating with the Stream API. /// - Returns: An array of loaded channels. @discardableResult public func loadMoreChannels(limit: Int? = nil) async throws -> [ChatChannel] { + guard await state.hasLoadedAllPreviousChannels == false else { return [] } let query = query.value let limit = limit ?? query.pagination.pageSize let count = await state.channels.count - return try await channelListUpdater.loadNextChannels( + let loadedChannels = try await channelListUpdater.loadNextChannels( query: query, limit: limit, loadedChannelsCount: count ) + await setHasLoadedAllPreviousChannels(loadedChannels.count < limit) + return loadedChannels } // MARK: - Internal @@ -122,12 +117,17 @@ public class ChannelList: @unchecked Sendable { } @MainActor private func resetStateAfterPrefill(query: ChannelListQuery, prefilledChannelsCount: Int) { + state.hasLoadedAllPreviousChannels = prefilledChannelsCount == 0 state.reset( query: query, minimumFetchLimit: prefilledChannelsCount ) state.skipNextInitialRemoteUpdate() } + + @MainActor private func setHasLoadedAllPreviousChannels(_ hasLoadedAllPreviousChannels: Bool) { + state.hasLoadedAllPreviousChannels = hasLoadedAllPreviousChannels + } } extension ChannelList { diff --git a/Sources/StreamChat/StateLayer/ChannelListState.swift b/Sources/StreamChat/StateLayer/ChannelListState.swift index bf3797b1ad1..c69c54d1fc3 100644 --- a/Sources/StreamChat/StateLayer/ChannelListState.swift +++ b/Sources/StreamChat/StateLayer/ChannelListState.swift @@ -38,6 +38,9 @@ import Foundation /// The query used for filtering the list of channels. public private(set) var query: ChannelListQuery + /// A Boolean value that returns whether pagination is finished. + var hasLoadedAllPreviousChannels = false + /// An array of channels for the specified ``ChannelListQuery``. @Published public internal(set) var channels: [ChatChannel] = [] diff --git a/Sources/StreamChat/Workers/ChannelListUpdater.swift b/Sources/StreamChat/Workers/ChannelListUpdater.swift index 187a1fc969b..bad1156a628 100644 --- a/Sources/StreamChat/Workers/ChannelListUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelListUpdater.swift @@ -43,14 +43,16 @@ class ChannelListUpdater: Worker, @unchecked Sendable { func prefill( group: GroupedChannelsGroup, for query: ChannelListQuery, + filter: (@Sendable (ChatChannel) -> Bool)? = nil, completion: (@Sendable (Result<[ChatChannel], Error>) -> Void)? = nil ) { + let channels = filter.map { group.channels.filter($0) } ?? group.channels nonisolated(unsafe) var savedChannels: [ChatChannel] = [] database.write { session in let queryDTO = session.saveQuery(query: query) queryDTO.channels.removeAll() - savedChannels = group.channels.compactMapLoggingError { channel in + savedChannels = 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 @@ -280,9 +282,13 @@ private extension ChannelListUpdater { } extension ChannelListUpdater { - @discardableResult func prefill(group: GroupedChannelsGroup, for query: ChannelListQuery) async throws -> [ChatChannel] { + @discardableResult func prefill( + group: GroupedChannelsGroup, + for query: ChannelListQuery, + filter: (@Sendable (ChatChannel) -> Bool)? = nil + ) async throws -> [ChatChannel] { try await withCheckedThrowingContinuation { continuation in - prefill(group: group, for: query) { result in + prefill(group: group, for: query, filter: filter) { result in continuation.resume(with: result) } } diff --git a/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift b/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift index ace469f0250..c61c31ca1af 100644 --- a/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift +++ b/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift @@ -70,11 +70,12 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable override func prefill( group: GroupedChannelsGroup, for query: ChannelListQuery, + filter: (@Sendable (ChatChannel) -> Bool)? = nil, completion: (@Sendable (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) + super.prefill(group: group, for: query, filter: filter, completion: completion) } override func markAllRead(completion: (@Sendable (Error?) -> Void)? = nil) { diff --git a/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift b/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift index 22138b02ee7..40b1d92236b 100644 --- a/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift +++ b/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift @@ -114,6 +114,16 @@ final class ChannelList_Tests: XCTestCase { XCTAssertEqual(env.channelListUpdaterMock.update_queries.last?.pagination, .init(pageSize: 2, offset: 3)) } + func test_prefill_whenNoChannelsWereSaved_loadMoreChannelsDoesNotRequestNextPage() async throws { + await setUpChannelList(usesMockedChannelUpdater: true, loadState: false, pageSize: 2) + + try await channelList.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: [], unreadChannels: 0)) + let loadedChannels = try await channelList.loadMoreChannels(limit: 2) + + XCTAssertEqual([], loadedChannels) + XCTAssertTrue(env.channelListUpdaterMock.update_queries.isEmpty) + } + func test_prefill_whenPrefilledCountExceedsPageSize_stateExposesAllPrefilledChannels() async throws { await setUpChannelList(usesMockedChannelUpdater: false, loadState: false, pageSize: 2) let prefilledChannels = try await makePrefilledChannels(count: 3, createdAtOffset: 0) From c6a08ff1c07042813cb012168805ab563e7d6279 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Tue, 5 May 2026 00:11:15 +0200 Subject: [PATCH 28/31] Recreate observer instead of changing FRC --- .../ChannelListController.swift | 51 +++++++++++----- .../BackgroundDatabaseObserver.swift | 41 +++---------- .../StreamChat/StateLayer/ChannelList.swift | 6 +- .../ChannelListState+Observer.swift | 61 ++++++++++--------- .../StateLayer/ChannelListState.swift | 19 +++--- .../StateLayerDatabaseObserver.swift | 33 ++-------- 6 files changed, 97 insertions(+), 114 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift index f3856578705..9f2eaef87b4 100644 --- a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift +++ b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift @@ -82,15 +82,26 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt } } - private(set) lazy var channelListObserver: BackgroundListDatabaseObserver = { - let request = ChannelDTO.channelListFetchRequest(query: self.query, chatClientConfig: client.config) - let observer = self.environment.createChannelListDatabaseObserver( + private(set) lazy var channelListObserver: BackgroundListDatabaseObserver = makeChannelListObserver( + query: query, + minimumFetchLimit: 0 + ) + + private func makeChannelListObserver( + query: ChannelListQuery, + minimumFetchLimit: Int + ) -> BackgroundListDatabaseObserver { + let request = ChannelDTO.channelListFetchRequest(query: query, chatClientConfig: client.config) + let fetchLimit = max(query.pagination.pageSize, minimumFetchLimit) + request.fetchLimit = fetchLimit + request.fetchBatchSize = fetchLimit + + let observer = environment.createChannelListDatabaseObserver( client.databaseContainer, request, { try $0.asModel() }, query.runtimeSortingValues ) - observer.onDidChange = { [weak self] changes in self?.delegateCallback { [weak self] in guard let self = self else { @@ -102,7 +113,7 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt } } return observer - }() + } var _basePublishers: Any? /// An internal backing object for all publicly available Combine publishers. We use it to simplify the way we expose @@ -118,10 +129,13 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt private let filter: (@Sendable (ChatChannel) -> Bool)? private let environment: Environment - private lazy var channelListLinker: ChannelListLinker = self.environment - .channelListLinkerBuilder( + private lazy var channelListLinker: ChannelListLinker = makeChannelListLinker(query: query) + + private func makeChannelListLinker(query: ChannelListQuery) -> ChannelListLinker { + environment.channelListLinkerBuilder( query, filter, client.config, client.databaseContainer, worker, client.channelWatcherHandler ) + } /// Creates a new `ChannelListController`. /// @@ -217,13 +231,22 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt // 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 - let request = ChannelDTO.channelListFetchRequest(query: self.query, chatClientConfig: self.client.config) - let fetchLimit = max(self.query.pagination.pageSize, savedChannels.count) - request.fetchLimit = fetchLimit - request.fetchBatchSize = fetchLimit - self.channelListObserver.resetFetchRequest(request) + + // Recreate observer + linker so they pick up the new groupKey, mirroring the + // state-layer ChannelListState.Observer.start(observing:) flow. + self.channelListObserver.stopObserving() + self.channelListObserver = self.makeChannelListObserver( + query: self.query, + minimumFetchLimit: savedChannels.count + ) + self.channelListLinker = self.makeChannelListLinker(query: self.query) + self.channelListLinker.start(with: self.client.eventNotificationCenter) + do { + try self.channelListObserver.startObserving() + } catch { + log.error("Failed to restart channel list observer after prefill: \(error)") + } + self.callback { completion?(nil) } diff --git a/Sources/StreamChat/Controllers/DatabaseObserver/BackgroundDatabaseObserver.swift b/Sources/StreamChat/Controllers/DatabaseObserver/BackgroundDatabaseObserver.swift index 70d4ac9ac5b..3b93a40d527 100644 --- a/Sources/StreamChat/Controllers/DatabaseObserver/BackgroundDatabaseObserver.swift +++ b/Sources/StreamChat/Controllers/DatabaseObserver/BackgroundDatabaseObserver.swift @@ -14,10 +14,9 @@ class BackgroundDatabaseObserver: @uncheck private let itemCreator: (DTO) throws -> Item private let itemReuseKeyPaths: (item: KeyPath, dto: KeyPath)? private let sorting: [SortValue] - private let fetchedResultsControllerType: NSFetchedResultsController.Type /// Used to observe the changes in the DB. - private(set) var frc: NSFetchedResultsController + let frc: NSFetchedResultsController /// Acts like the `NSFetchedResultsController`'s delegate and aggregates the reported changes into easily consumable form. let changeAggregator: ListChangeAggregator @@ -74,7 +73,6 @@ class BackgroundDatabaseObserver: @uncheck self.itemCreator = itemCreator self.itemReuseKeyPaths = itemReuseKeyPaths self.sorting = sorting - self.fetchedResultsControllerType = fetchedResultsControllerType changeAggregator = ListChangeAggregator(itemCreator: itemCreator) frc = fetchedResultsControllerType.init( fetchRequest: fetchRequest, @@ -90,39 +88,20 @@ class BackgroundDatabaseObserver: @uncheck } } - /// Replaces the underlying fetch request and re-fetches items while preserving delegate wiring. - @discardableResult - func resetFetchRequest(_ fetchRequest: NSFetchRequest) -> [Item] { + /// Stops observing changes from the underlying fetch results controller. + /// + /// Releases the FRC delegate so further context changes don't trigger callbacks, and clears + /// the change closures so a `DispatchQueue.main.async` already enqueued by a previous change + /// can't fire on a stale instance. + func stopObserving() { let context = frc.managedObjectContext - nonisolated(unsafe) var items: [Item] = [] - nonisolated(unsafe) var shouldNotifyDidChange = false context.performAndWait { - let wasInitialized = self.isInitialized self.frc.delegate = nil - self.frc = self.fetchedResultsControllerType.init( - fetchRequest: fetchRequest, - managedObjectContext: context, - sectionNameKeyPath: nil, - cacheName: nil - ) - if wasInitialized { - self.frc.delegate = self.changeAggregator - } + self.changeAggregator.onDidChange = nil self._items = nil - do { - try self.frc.performFetch() - } catch { - log.error("Failed to reset fetch request: \(error)") - return - } - items = self.updateItems(nil) - shouldNotifyDidChange = wasInitialized - } - if shouldNotifyDidChange { - let changes: [ListChange] = items.enumerated().map { .insert($1, index: IndexPath(item: $0, section: 0)) } - notifyDidChange(changes: changes) } - return items + isInitialized = false + onDidChange = nil } /// Starts observing the changes in the database. diff --git a/Sources/StreamChat/StateLayer/ChannelList.swift b/Sources/StreamChat/StateLayer/ChannelList.swift index 89a24b44a2a..c95021500d8 100644 --- a/Sources/StreamChat/StateLayer/ChannelList.swift +++ b/Sources/StreamChat/StateLayer/ChannelList.swift @@ -117,11 +117,7 @@ public class ChannelList: @unchecked Sendable { } @MainActor private func resetStateAfterPrefill(query: ChannelListQuery, prefilledChannelsCount: Int) { - state.hasLoadedAllPreviousChannels = prefilledChannelsCount == 0 - state.reset( - query: query, - minimumFetchLimit: prefilledChannelsCount - ) + state.reset(to: query, prefilledCount: prefilledChannelsCount) state.skipNextInitialRemoteUpdate() } diff --git a/Sources/StreamChat/StateLayer/ChannelListState+Observer.swift b/Sources/StreamChat/StateLayer/ChannelListState+Observer.swift index 76379a27962..387a7bb88e8 100644 --- a/Sources/StreamChat/StateLayer/ChannelListState+Observer.swift +++ b/Sources/StreamChat/StateLayer/ChannelListState+Observer.swift @@ -5,18 +5,17 @@ import Foundation extension ChannelListState { - final class Observer { - private let channelListObserver: StateLayerDatabaseObserver + @MainActor final class Observer { + private var channelListObserver: StateLayerDatabaseObserver? private let clientConfig: ChatClientConfig - private let channelListLinker: ChannelListLinker + private var channelListLinker: ChannelListLinker? private let channelListUpdater: ChannelListUpdater + private let channelWatcherHandler: ChannelWatcherHandling private let database: DatabaseContainer - private let dynamicFilter: ((ChatChannel) -> Bool)? + private let dynamicFilter: (@Sendable (ChatChannel) -> Bool)? private let eventNotificationCenter: EventNotificationCenter - private let query: ChannelListQuery init( - query: ChannelListQuery, dynamicFilter: (@Sendable (ChatChannel) -> Bool)?, clientConfig: ChatClientConfig, channelListUpdater: ChannelListUpdater, @@ -28,19 +27,37 @@ extension ChannelListState { self.channelListUpdater = channelListUpdater self.database = database self.dynamicFilter = dynamicFilter - self.query = query self.eventNotificationCenter = eventNotificationCenter - - channelListObserver = StateLayerDatabaseObserver( + self.channelWatcherHandler = channelWatcherHandler + } + + struct Handlers { + let channelsDidChange: @Sendable @MainActor ([ChatChannel]) async -> Void + } + + func start( + observing query: ChannelListQuery, + minimumFetchLimit: Int = 0, + handlers: Handlers + ) -> [ChatChannel] { + channelListObserver?.stopObserving() + + let fetchRequest = ChannelDTO.channelListFetchRequest( + query: query, + chatClientConfig: clientConfig + ) + let fetchLimit = max(query.pagination.pageSize, minimumFetchLimit) + fetchRequest.fetchLimit = fetchLimit + fetchRequest.fetchBatchSize = fetchLimit + + let channelListObserver = StateLayerDatabaseObserver( database: database, - fetchRequest: ChannelDTO.channelListFetchRequest( - query: query, - chatClientConfig: clientConfig - ), + fetchRequest: fetchRequest, itemCreator: { try $0.asModel() }, itemReuseKeyPaths: (\ChatChannel.cid.rawValue, \ChannelDTO.cid), runtimeSorting: query.runtimeSortingValues ) + self.channelListObserver = channelListObserver channelListLinker = ChannelListLinker( query: query, filter: dynamicFilter, @@ -49,28 +66,14 @@ extension ChannelListState { worker: channelListUpdater, channelWatcherHandler: channelWatcherHandler ) - } - - struct Handlers { - let channelsDidChange: @Sendable @MainActor ([ChatChannel]) async -> Void - } - - func start(with handlers: Handlers) -> [ChatChannel] { + do { - channelListLinker.start(with: eventNotificationCenter) + channelListLinker?.start(with: eventNotificationCenter) return try channelListObserver.startObserving(didChange: handlers.channelsDidChange) } catch { log.error("Failed to start the channel list observer for query: \(query)") return [] } } - - func resetFetchRequest(query: ChannelListQuery, minimumFetchLimit: Int) -> [ChatChannel] { - let request = ChannelDTO.channelListFetchRequest(query: query, chatClientConfig: clientConfig) - let fetchLimit = max(query.pagination.pageSize, minimumFetchLimit) - request.fetchLimit = fetchLimit - request.fetchBatchSize = fetchLimit - return channelListObserver.resetFetchRequest(request) - } } } diff --git a/Sources/StreamChat/StateLayer/ChannelListState.swift b/Sources/StreamChat/StateLayer/ChannelListState.swift index c69c54d1fc3..1b7115bbe1c 100644 --- a/Sources/StreamChat/StateLayer/ChannelListState.swift +++ b/Sources/StreamChat/StateLayer/ChannelListState.swift @@ -7,7 +7,7 @@ import Foundation /// Represents a list of channels matching to the specified query. @MainActor public final class ChannelListState: ObservableObject { - private var observer: Observer + private let observer: Observer private var shouldSkipInitialRemoteUpdate = false private var handlers: Observer.Handlers { .init(channelsDidChange: { [weak self] in self?.channels = $0 }) @@ -24,7 +24,6 @@ import Foundation ) { self.query = query observer = Observer( - query: query, dynamicFilter: dynamicFilter, clientConfig: clientConfig, channelListUpdater: channelListUpdater, @@ -32,7 +31,7 @@ import Foundation eventNotificationCenter: eventNotificationCenter, channelWatcherHandler: channelWatcherHandler ) - channels = observer.start(with: handlers) + channels = observer.start(observing: query, handlers: handlers) } /// The query used for filtering the list of channels. @@ -44,6 +43,8 @@ import Foundation /// An array of channels for the specified ``ChannelListQuery``. @Published public internal(set) var channels: [ChatChannel] = [] + // MARK: - Internal + func skipNextInitialRemoteUpdate() { shouldSkipInitialRemoteUpdate = true } @@ -53,11 +54,13 @@ import Foundation return shouldSkipInitialRemoteUpdate } - func reset( - query: ChannelListQuery, - minimumFetchLimit: Int - ) { + func reset(to query: ChannelListQuery, prefilledCount: Int) { + hasLoadedAllPreviousChannels = prefilledCount == 0 self.query = query - channels = observer.resetFetchRequest(query: query, minimumFetchLimit: minimumFetchLimit) + channels = observer.start( + observing: query, + minimumFetchLimit: prefilledCount, + handlers: handlers + ) } } diff --git a/Sources/StreamChat/StateLayer/DatabaseObserver/StateLayerDatabaseObserver.swift b/Sources/StreamChat/StateLayer/DatabaseObserver/StateLayerDatabaseObserver.swift index ed3da8d0a21..df13e77ff82 100644 --- a/Sources/StreamChat/StateLayer/DatabaseObserver/StateLayerDatabaseObserver.swift +++ b/Sources/StreamChat/StateLayer/DatabaseObserver/StateLayerDatabaseObserver.swift @@ -18,15 +18,14 @@ class ListResult: DatabaseObserverType {} /// - Note: Requires the ``DatabaseContainer/stateLayerContext`` which is immediately synchronized. final class StateLayerDatabaseObserver: @unchecked Sendable { private let changeAggregator: ListChangeAggregator - private var frc: NSFetchedResultsController + private let frc: NSFetchedResultsController let itemCreator: (DTO) throws -> Item let itemReuseKeyPaths: (item: KeyPath, dto: KeyPath)? let sorting: [SortValue] - var request: NSFetchRequest + let request: NSFetchRequest let context: NSManagedObjectContext // Keep track of last items for reuse private var reuseItems: [Item]? - private var isObserving = false private init( context: NSManagedObjectContext, @@ -189,38 +188,18 @@ extension StateLayerDatabaseObserver where ResultType == ListResult { let items = self.updateItems(changes) onContextDidChange(items, changes) } - isObserving = true frc.delegate = changeAggregator try frc.performFetch() return items } - /// Replaces the underlying fetch request and returns the refreshed items. - @discardableResult func resetFetchRequest(_ fetchRequest: NSFetchRequest) -> [Item] { - nonisolated(unsafe) var collection: [Item] = [] + func stopObserving() { context.performAndWait { - self.request = fetchRequest self.frc.delegate = nil - self.frc = NSFetchedResultsController( - fetchRequest: fetchRequest, - managedObjectContext: self.context, - sectionNameKeyPath: nil, - cacheName: nil - ) - if self.isObserving { - self.frc.delegate = self.changeAggregator - } - self.reuseItems = nil - do { - try self.frc.performFetch() - } catch { - log.error("Failed to reset fetch request: \(error)") - collection = [] - return - } - collection = self.updateItems(nil) + // Drop the change callback so any in-flight Task.mainActor enqueued by a previous + // FRC change becomes a no-op and cannot overwrite the new observer's state. + self.changeAggregator.onDidChange = nil } - return collection } private func updateItems(_ changes: [ListChange]?) -> [Item] { From b80d551af28b62afbae0886af42e42285c7da64c Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Mon, 11 May 2026 14:51:20 +0300 Subject: [PATCH 29/31] Support pagination parameters in query grouped channels --- .../Payloads/ChannelListPayload.swift | 32 ++++- Sources/StreamChat/ChatClient.swift | 9 +- .../StreamChat/Models/GroupedChannels.swift | 28 +++- .../Repositories/SyncOperations.swift | 5 +- .../StreamChat/StateLayer/ChannelList.swift | 40 +++++- .../StateLayer/ChannelListState.swift | 16 ++- .../StateLayer/ChatClient+Factory.swift | 8 +- .../Workers/ChannelListUpdater.swift | 97 +++++++++++-- .../StreamChat/State/ChannelList_Mock.swift | 2 + .../Spy/ChannelListUpdater_Spy.swift | 39 +++++- .../Endpoints/ChannelEndpoints_Tests.swift | 8 +- .../Payloads/ChannelListPayload_Tests.swift | 69 +++++++++ Tests/StreamChatTests/ChatClient_Tests.swift | 7 +- .../ChannelListController_Tests.swift | 18 +-- .../Repositories/SyncRepository_Tests.swift | 16 +-- .../StateLayer/ChannelList_Tests.swift | 132 ++++++++++++++++-- .../Workers/ChannelListUpdater_Tests.swift | 116 +++++++++++++++ 17 files changed, 571 insertions(+), 71 deletions(-) diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift index 87fca3779c2..69c6547b008 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift @@ -27,16 +27,29 @@ extension ChannelListPayload: Decodable { final class GroupedQueryChannelsRequestBody: Encodable, Sendable { let limit: Int? + let groups: [String: GroupedQueryChannelsRequestGroup]? let watch: Bool let presence: Bool - init(limit: Int?, watch: Bool, presence: Bool) { + init( + limit: Int?, + groups: [String: GroupedQueryChannelsRequestGroup]?, + watch: Bool, + presence: Bool + ) { self.limit = limit + self.groups = groups self.watch = watch self.presence = presence } } +struct GroupedQueryChannelsRequestGroup: Encodable, Sendable { + let limit: Int? + let next: String? + let prev: String? +} + final class GroupedQueryChannelsPayload: Decodable, Sendable { let groups: [String: GroupedQueryChannelsGroupPayload] let duration: String @@ -61,21 +74,34 @@ final class GroupedQueryChannelsPayload: Decodable, Sendable { final class GroupedQueryChannelsGroupPayload: Decodable, Sendable { let channels: [ChannelPayload] let unreadChannels: Int - - init(channels: [ChannelPayload], unreadChannels: Int) { + let next: String? + let prev: String? + + init( + channels: [ChannelPayload], + unreadChannels: Int, + next: String? = nil, + prev: String? = nil + ) { self.channels = channels self.unreadChannels = unreadChannels + self.next = next + self.prev = prev } enum CodingKeys: String, CodingKey { case channels case unreadChannels = "unread_channels" + case next + case prev } 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 + next = try container.decodeIfPresent(String.self, forKey: .next) + prev = try container.decodeIfPresent(String.self, forKey: .prev) } } diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index a8f8cb1d65b..87279b37e83 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -654,14 +654,16 @@ public class ChatClient: @unchecked Sendable { /// Queries grouped channel groups for the app. public func queryGroupedChannels( limit: Int? = nil, - watch: Bool = false, presence: Bool = false, + watch: Bool = false, + groupHandler: @escaping @Sendable (String, ChatChannel) -> String, completion: @escaping @MainActor (Result) -> Void ) { channelListUpdater.queryGroupedChannels( limit: limit, watch: watch, presence: presence, + groupHandler: groupHandler, completion: completion ) } @@ -669,11 +671,12 @@ public class ChatClient: @unchecked Sendable { /// Queries grouped channel groups for the app. public func queryGroupedChannels( limit: Int? = nil, + presence: Bool = false, watch: Bool = false, - presence: Bool = false + groupHandler: @escaping @Sendable (String, ChatChannel) -> String ) async throws -> GroupedChannels { try await withCheckedThrowingContinuation { continuation in - queryGroupedChannels(limit: limit, watch: watch, presence: presence) { result in + queryGroupedChannels(limit: limit, presence: presence, watch: watch, groupHandler: groupHandler) { result in continuation.resume(with: result) } } diff --git a/Sources/StreamChat/Models/GroupedChannels.swift b/Sources/StreamChat/Models/GroupedChannels.swift index c9b6198ccbf..748f85088a9 100644 --- a/Sources/StreamChat/Models/GroupedChannels.swift +++ b/Sources/StreamChat/Models/GroupedChannels.swift @@ -5,19 +5,23 @@ import Foundation /// A grouped channels response returned by `ChatClient.queryGroupedChannels`. -public struct GroupedChannels: Equatable, Sendable { +public struct GroupedChannels: Sendable { /// The grouped channel groups returned by the backend, keyed by group name. public let groups: [String: GroupedChannelsGroup] init( - groups: [String: GroupedChannelsGroup] + groups: [String: GroupedChannelsGroup], + groupHandler: @escaping @Sendable (String, ChatChannel) -> String ) { self.groups = groups + self.groupHandler = groupHandler } + + let groupHandler: @Sendable (String, ChatChannel) -> String } /// A grouped channels group returned by `ChatClient.queryGroupedChannels`. -public struct GroupedChannelsGroup: Equatable, Sendable { +public struct GroupedChannelsGroup: Sendable { /// The group key as returned by the backend (e.g. `"all"`, `"new"`, `"current"`). public let groupKey: String @@ -27,10 +31,17 @@ public struct GroupedChannelsGroup: Equatable, Sendable { /// The total unread channel count in the group. public let unreadChannels: Int + let next: String? + let prev: String? + let groupHandler: @Sendable (String, ChatChannel) -> String + init( groupKey: String, channels: [ChatChannel], - unreadChannels: Int + unreadChannels: Int, + next: String? = nil, + prev: String? = nil, + groupHandler: @escaping @Sendable (String, ChatChannel) -> String ) { self.groupKey = groupKey self.channels = channels @@ -41,5 +52,14 @@ public struct GroupedChannelsGroup: Equatable, Sendable { } self.unreadChannels = max(unreadChannels, derivedUnreadChannels) + self.next = next + self.prev = prev + self.groupHandler = groupHandler } } + +struct GroupChannelsPagination: Sendable { + let groupKey: String + let next: String? + let prev: String? +} diff --git a/Sources/StreamChat/Repositories/SyncOperations.swift b/Sources/StreamChat/Repositories/SyncOperations.swift index 53bf05d6472..e0c47eaaac8 100644 --- a/Sources/StreamChat/Repositories/SyncOperations.swift +++ b/Sources/StreamChat/Repositories/SyncOperations.swift @@ -137,9 +137,12 @@ final class SyncGroupedChannelsOperation: AsyncOperation, @unchecked Sendable { return } + let defaultGroupHandler: @Sendable (String, ChatChannel) -> String = { key, _ in key } + let groupHandler = channelLists.lazy.compactMap(\.groupHandler).first ?? defaultGroupHandler + Task { do { - let groupedChannels = try await channelListUpdater.queryGroupedChannels() + let groupedChannels = try await channelListUpdater.queryGroupedChannels(groupHandler: groupHandler) let returnedChannelIds = groupedChannels.groups.values .flatMap(\.channels) .map(\.cid) diff --git a/Sources/StreamChat/StateLayer/ChannelList.swift b/Sources/StreamChat/StateLayer/ChannelList.swift index c95021500d8..f8add31e587 100644 --- a/Sources/StreamChat/StateLayer/ChannelList.swift +++ b/Sources/StreamChat/StateLayer/ChannelList.swift @@ -9,17 +9,20 @@ public class ChannelList: @unchecked Sendable { private let channelListUpdater: ChannelListUpdater private let client: ChatClient private let dynamicFilter: (@Sendable (ChatChannel) -> Bool)? + let groupHandler: (@Sendable (String, ChatChannel) -> String)? let query: AllocatedUnfairLock @MainActor private var stateBuilder: StateBuilder init( query: ChannelListQuery, dynamicFilter: (@Sendable (ChatChannel) -> Bool)?, + groupHandler: (@Sendable (String, ChatChannel) -> String)?, client: ChatClient, environment: Environment = .init() ) { self.client = client self.dynamicFilter = dynamicFilter + self.groupHandler = groupHandler self.query = AllocatedUnfairLock(query) let channelListUpdater = environment.channelListUpdater( client.databaseContainer, @@ -70,7 +73,11 @@ public class ChannelList: @unchecked Sendable { } let savedChannels = try await channelListUpdater.prefill(group: group, for: updatedQuery, filter: dynamicFilter) - await resetStateAfterPrefill(query: updatedQuery, prefilledChannelsCount: savedChannels.count) + await resetStateAfterPrefill( + query: updatedQuery, + prefilledChannelsCount: savedChannels.count, + next: group.next + ) client.syncRepository.startTrackingChannelList(self) } @@ -98,6 +105,9 @@ public class ChannelList: @unchecked Sendable { guard await state.hasLoadedAllPreviousChannels == false else { return [] } let query = query.value let limit = limit ?? query.pagination.pageSize + if let groupKey = query.groupKey, let groupHandler = groupHandler { + return try await loadMoreGroupedChannels(groupKey: groupKey, groupHandler: groupHandler, query: query, limit: limit) + } let count = await state.channels.count let loadedChannels = try await channelListUpdater.loadNextChannels( query: query, @@ -107,17 +117,37 @@ public class ChannelList: @unchecked Sendable { await setHasLoadedAllPreviousChannels(loadedChannels.count < limit) return loadedChannels } - + // MARK: - Internal - + func refreshLoadedChannels() async throws -> Set { let query = query.value let count = await state.channels.count return try await channelListUpdater.refreshLoadedChannels(for: query, channelCount: count) } - @MainActor private func resetStateAfterPrefill(query: ChannelListQuery, prefilledChannelsCount: Int) { - state.reset(to: query, prefilledCount: prefilledChannelsCount) + private func loadMoreGroupedChannels( + groupKey: String, + groupHandler: @escaping @Sendable (String, ChatChannel) -> String, + query: ChannelListQuery, + limit: Int + ) async throws -> [ChatChannel] { + let cursor = await state.groupPaginationCursor + let pagination = GroupChannelsPagination(groupKey: groupKey, next: cursor, prev: nil) + let result = try await channelListUpdater.queryGroupedChannels( + limit: limit, + pagination: pagination, + groupHandler: groupHandler + ) + guard let newGroup = result.groups[groupKey] else { return [] } + let appended = try await channelListUpdater.appendToQuery(group: newGroup, for: query, filter: dynamicFilter) + await state.setGroupPaginationCursor(newGroup.next) + await setHasLoadedAllPreviousChannels(newGroup.next == nil) + return appended + } + + @MainActor private func resetStateAfterPrefill(query: ChannelListQuery, prefilledChannelsCount: Int, next: String?) { + state.reset(to: query, prefilledCount: prefilledChannelsCount, next: next) state.skipNextInitialRemoteUpdate() } diff --git a/Sources/StreamChat/StateLayer/ChannelListState.swift b/Sources/StreamChat/StateLayer/ChannelListState.swift index 1b7115bbe1c..f2f9228efaf 100644 --- a/Sources/StreamChat/StateLayer/ChannelListState.swift +++ b/Sources/StreamChat/StateLayer/ChannelListState.swift @@ -39,12 +39,15 @@ import Foundation /// A Boolean value that returns whether pagination is finished. var hasLoadedAllPreviousChannels = false - + + /// The next-page cursor for the prefilled group, used to paginate via the grouped endpoint. + var groupPaginationCursor: String? + /// An array of channels for the specified ``ChannelListQuery``. @Published public internal(set) var channels: [ChatChannel] = [] // MARK: - Internal - + func skipNextInitialRemoteUpdate() { shouldSkipInitialRemoteUpdate = true } @@ -54,8 +57,9 @@ import Foundation return shouldSkipInitialRemoteUpdate } - func reset(to query: ChannelListQuery, prefilledCount: Int) { - hasLoadedAllPreviousChannels = prefilledCount == 0 + func reset(to query: ChannelListQuery, prefilledCount: Int, next: String?) { + hasLoadedAllPreviousChannels = next == nil + groupPaginationCursor = next self.query = query channels = observer.start( observing: query, @@ -63,4 +67,8 @@ import Foundation handlers: handlers ) } + + func setGroupPaginationCursor(_ cursor: String?) { + groupPaginationCursor = cursor + } } diff --git a/Sources/StreamChat/StateLayer/ChatClient+Factory.swift b/Sources/StreamChat/StateLayer/ChatClient+Factory.swift index 7c5135f661d..f599382a612 100644 --- a/Sources/StreamChat/StateLayer/ChatClient+Factory.swift +++ b/Sources/StreamChat/StateLayer/ChatClient+Factory.swift @@ -49,7 +49,13 @@ extension ChatClient { with query: ChannelListQuery, dynamicFilter: (@Sendable (ChatChannel) -> Bool)? = nil ) -> ChannelList { - ChannelList(query: query, dynamicFilter: dynamicFilter, client: self) + ChannelList(query: query, dynamicFilter: dynamicFilter, groupHandler: nil, client: self) + } + + public func makeChannelList(with group: GroupedChannelsGroup) -> ChannelList { + var query = ChannelListQuery(filter: .and([])) + query.groupKey = group.groupKey + return ChannelList(query: query, dynamicFilter: nil, groupHandler: group.groupHandler, client: self) } } diff --git a/Sources/StreamChat/Workers/ChannelListUpdater.swift b/Sources/StreamChat/Workers/ChannelListUpdater.swift index bad1156a628..dac38c36755 100644 --- a/Sources/StreamChat/Workers/ChannelListUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelListUpdater.swift @@ -69,6 +69,33 @@ class ChannelListUpdater: Worker, @unchecked Sendable { } } + func appendToQuery( + group: GroupedChannelsGroup, + for query: ChannelListQuery, + filter: (@Sendable (ChatChannel) -> Bool)? = nil, + completion: (@Sendable (Result<[ChatChannel], Error>) -> Void)? = nil + ) { + let channels = filter.map { group.channels.filter($0) } ?? group.channels + nonisolated(unsafe) var savedChannels: [ChatChannel] = [] + database.write { session in + let queryDTO = session.saveQuery(query: query) + savedChannels = channels.compactMapLoggingError { channel in + guard let channelDTO = session.channel(cid: channel.cid) else { + log.warning("Append 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 @Sendable (Result, Error>) -> Void) { guard channelCount > 0 else { completion(.success(Set())) @@ -196,25 +223,50 @@ class ChannelListUpdater: Worker, @unchecked Sendable { } /// Queries grouped channel groups for the app. + /// + /// When `pagination` is non-nil, only that single group is paginated using its cursor and the + /// response payload contains only that group. Unread-channel counts are not persisted in the + /// paginated case since they reflect just the requested group. func queryGroupedChannels( limit: Int? = nil, + pagination: GroupChannelsPagination? = nil, watch: Bool = false, presence: Bool = false, + groupHandler: @escaping @Sendable (String, ChatChannel) -> String, completion: @escaping @MainActor (Result) -> Void ) { - let request = GroupedQueryChannelsRequestBody( - limit: limit, - watch: watch, - presence: presence - ) + let request: GroupedQueryChannelsRequestBody + if let pagination { + let group = GroupedQueryChannelsRequestGroup( + limit: limit, + next: pagination.next, + prev: pagination.prev + ) + request = GroupedQueryChannelsRequestBody( + limit: nil, + groups: [pagination.groupKey: group], + watch: watch, + presence: presence + ) + } else { + request = GroupedQueryChannelsRequestBody( + limit: limit, + groups: nil, + watch: watch, + presence: presence + ) + } let endpoint: Endpoint = .groupedChannels(request: request) + let isPaginating = pagination != nil 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) + if !isPaginating { + let groupedUnreadChannels = payload.groups.mapValues(\.unreadChannels) + try session.saveCurrentUserGroupedUnreadChannels(groupedUnreadChannels) + } var groups: [String: GroupedChannelsGroup] = [:] for (name, groupPayload) in payload.groups { @@ -225,10 +277,13 @@ class ChannelListUpdater: Worker, @unchecked Sendable { groups[name] = GroupedChannelsGroup( groupKey: name, channels: channels, - unreadChannels: groupPayload.unreadChannels + unreadChannels: groupPayload.unreadChannels, + next: groupPayload.next, + prev: groupPayload.prev, + groupHandler: groupHandler ) } - return GroupedChannels(groups: groups) + return GroupedChannels(groups: groups, groupHandler: groupHandler) }, completion: { result in DispatchQueue.main.async { completion(result) @@ -294,6 +349,18 @@ extension ChannelListUpdater { } } + @discardableResult func appendToQuery( + group: GroupedChannelsGroup, + for query: ChannelListQuery, + filter: (@Sendable (ChatChannel) -> Bool)? = nil + ) async throws -> [ChatChannel] { + try await withCheckedThrowingContinuation { continuation in + appendToQuery(group: group, for: query, filter: filter) { result in + continuation.resume(with: result) + } + } + } + @discardableResult func update(channelListQuery: ChannelListQuery) async throws -> [ChatChannel] { try await withCheckedThrowingContinuation { continuation in update(channelListQuery: channelListQuery) { result in @@ -304,11 +371,19 @@ extension ChannelListUpdater { func queryGroupedChannels( limit: Int? = nil, + pagination: GroupChannelsPagination? = nil, watch: Bool = false, - presence: Bool = false + presence: Bool = false, + groupHandler: @escaping @Sendable (String, ChatChannel) -> String ) async throws -> GroupedChannels { try await withCheckedThrowingContinuation { continuation in - queryGroupedChannels(limit: limit, watch: watch, presence: presence) { result in + queryGroupedChannels( + limit: limit, + pagination: pagination, + watch: watch, + presence: presence, + groupHandler: groupHandler + ) { result in continuation.resume(with: result) } } diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/State/ChannelList_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/State/ChannelList_Mock.swift index abb978c9a48..1e63701d94a 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/State/ChannelList_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/State/ChannelList_Mock.swift @@ -19,12 +19,14 @@ public class ChannelList_Mock: ChannelList, @unchecked Sendable { override init( query: ChannelListQuery, dynamicFilter: (@Sendable (ChatChannel) -> Bool)? = nil, + groupHandler: (@Sendable (String, ChatChannel) -> String)? = nil, client: ChatClient, environment: ChannelList.Environment = .init() ) { super.init( query: query, dynamicFilter: dynamicFilter, + groupHandler: groupHandler, client: client, environment: environment ) diff --git a/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift b/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift index c61c31ca1af..cf4808c4c95 100644 --- a/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift +++ b/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift @@ -23,8 +23,14 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable @Atomic var refreshLoadedChannels_channelCounts: [Int] = [] @Atomic var queryGroupedChannels_callCount = 0 + @Atomic var queryGroupedChannels_limits: [Int?] = [] + @Atomic var queryGroupedChannels_paginations: [GroupChannelsPagination?] = [] @Atomic var queryGroupedChannels_result: Result? + @Atomic var appendToQuery_queries: [ChannelListQuery] = [] + @Atomic var appendToQuery_channels: [[ChatChannel]] = [] + @Atomic var appendToQuery_result: Result<[ChatChannel], Error>? + @Atomic var markAllRead_completion: (@Sendable (Error?) -> Void)? var startWatchingChannels_callCount = 0 @@ -50,7 +56,12 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable refreshLoadedChannels_channelCounts.removeAll() queryGroupedChannels_callCount = 0 + queryGroupedChannels_limits.removeAll() + queryGroupedChannels_paginations.removeAll() queryGroupedChannels_result = nil + appendToQuery_queries.removeAll() + appendToQuery_channels.removeAll() + appendToQuery_result = nil markAllRead_completion = nil startWatchingChannels_cids.removeAll() @@ -102,17 +113,43 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable override func queryGroupedChannels( limit: Int? = nil, + pagination: GroupChannelsPagination? = nil, watch: Bool = false, presence: Bool = false, + groupHandler: @escaping @Sendable (String, ChatChannel) -> String = { key, _ in key }, completion: @escaping @MainActor (Result) -> Void ) { _queryGroupedChannels_callCount.mutate { $0 += 1 } + _queryGroupedChannels_limits.mutate { $0.append(limit) } + _queryGroupedChannels_paginations.mutate { $0.append(pagination) } if let result = queryGroupedChannels_result { DispatchQueue.main.async { completion(result) } } else { - super.queryGroupedChannels(limit: limit, watch: watch, presence: presence, completion: completion) + super.queryGroupedChannels( + limit: limit, + pagination: pagination, + watch: watch, + presence: presence, + groupHandler: groupHandler, + completion: completion + ) + } + } + + override func appendToQuery( + group: GroupedChannelsGroup, + for query: ChannelListQuery, + filter: (@Sendable (ChatChannel) -> Bool)? = nil, + completion: (@Sendable (Result<[ChatChannel], Error>) -> Void)? = nil + ) { + _appendToQuery_queries.mutate { $0.append(query) } + _appendToQuery_channels.mutate { $0.append(group.channels) } + if let result = appendToQuery_result { + completion?(result) + } else { + super.appendToQuery(group: group, for: query, filter: filter, completion: completion) } } diff --git a/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift index 5158322a91d..ca7a6c4d3ba 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift @@ -44,10 +44,10 @@ 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) + (.init(limit: 10, groups: nil, watch: true, presence: false), true), + (.init(limit: 10, groups: nil, watch: false, presence: true), true), + (.init(limit: 10, groups: nil, watch: true, presence: true), true), + (.init(limit: 10, groups: nil, watch: false, presence: false), false) ] for (request, requiresConnectionId) in testCases { diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift index f37199f1950..2e0eb782b40 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift @@ -123,6 +123,75 @@ final class ChannelListPayload_Tests: XCTestCase { XCTAssertEqual(payload.duration, "12ms") } + func test_groupedQueryChannelsPayload_decodesNextAndPrevCursors() throws { + let json = """ + { + "groups": { + "current": { + "channels": [], + "unread_channels": 0, + "next": "current-next-cursor", + "prev": "current-prev-cursor" + } + }, + "duration": "5ms" + } + """.data(using: .utf8)! + + let payload = try JSONDecoder.default.decode(GroupedQueryChannelsPayload.self, from: json) + + XCTAssertEqual("current-next-cursor", payload.groups["current"]?.next) + XCTAssertEqual("current-prev-cursor", payload.groups["current"]?.prev) + } + + func test_groupedQueryChannelsPayload_cursorsAreNilWhenMissing() throws { + let json = """ + { + "groups": { + "all": { "channels": [], "unread_channels": 0 } + }, + "duration": "5ms" + } + """.data(using: .utf8)! + + let payload = try JSONDecoder.default.decode(GroupedQueryChannelsPayload.self, from: json) + + XCTAssertNil(payload.groups["all"]?.next) + XCTAssertNil(payload.groups["all"]?.prev) + } + + func test_groupedQueryChannelsRequestBody_allGroups_encodesWithoutGroupsKey() throws { + let body = GroupedQueryChannelsRequestBody(limit: 10, groups: nil, watch: true, presence: false) + + let encoded = try JSONEncoder.stream.encode(body) + let json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] + + XCTAssertEqual(10, json?["limit"] as? Int) + XCTAssertEqual(true, json?["watch"] as? Bool) + XCTAssertEqual(false, json?["presence"] as? Bool) + XCTAssertNil(json?["groups"]) + } + + func test_groupedQueryChannelsRequestBody_paginatedGroup_encodesWithGroupsKeyAndCursor() throws { + let body = GroupedQueryChannelsRequestBody( + limit: nil, + groups: ["old": .init(limit: 5, next: "old-cursor", prev: nil)], + watch: false, + presence: true + ) + + let encoded = try JSONEncoder.stream.encode(body) + let json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] + let groups = json?["groups"] as? [String: [String: Any]] + + XCTAssertNil(json?["limit"]) + XCTAssertEqual(false, json?["watch"] as? Bool) + XCTAssertEqual(true, json?["presence"] as? Bool) + XCTAssertEqual(5, groups?["old"]?["limit"] as? Int) + XCTAssertEqual("old-cursor", groups?["old"]?["next"] as? String) + XCTAssertNil(groups?["old"]?["prev"]) + } + func test_groupedQueryChannelsPayload_defaultsUnreadCountersWhenMissing() throws { let channelId = ChannelId(type: .messaging, id: "bucket-channel") let json = """ diff --git a/Tests/StreamChatTests/ChatClient_Tests.swift b/Tests/StreamChatTests/ChatClient_Tests.swift index 1bd616b98ba..5fb69f3a5ed 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -269,7 +269,7 @@ final class ChatClient_Tests: XCTestCase { let secondCid = ChannelId.unique let thirdCid = ChannelId.unique - let request = GroupedQueryChannelsRequestBody(limit: 4, watch: true, presence: false) + let request = GroupedQueryChannelsRequestBody(limit: 4, groups: nil, watch: true, presence: false) let expectedEndpoint: Endpoint = .groupedChannels(request: request) let payload = GroupedQueryChannelsPayload( groups: [ @@ -293,7 +293,7 @@ final class ChatClient_Tests: XCTestCase { var receivedGroupedChannels: GroupedChannels? var receivedError: Error? - client.queryGroupedChannels(limit: 4, watch: true, presence: false) { result in + client.queryGroupedChannels(limit: 4, presence: false, watch: true, groupHandler: { key, _ in key }) { result in switch result { case let .success(groupedChannels): receivedGroupedChannels = groupedChannels @@ -329,7 +329,8 @@ final class ChatClient_Tests: XCTestCase { let group = GroupedChannelsGroup( groupKey: "all", channels: [firstChannel, secondChannel, thirdChannel], - unreadChannels: 0 + unreadChannels: 0, + groupHandler: { key, _ in key } ) XCTAssertEqual(group.unreadChannels, 2) diff --git a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift index afcfea2b496..65bd2d39399 100644 --- a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift @@ -231,7 +231,7 @@ final class ChannelListController_Tests: XCTestCase { ] let prefillExpectation = expectation(description: "Prefill completes") - controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) { error in + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0, groupHandler: { key, _ in key })) { error in XCTAssertNil(error) prefillExpectation.fulfill() } @@ -261,7 +261,7 @@ final class ChannelListController_Tests: XCTestCase { ] let prefillExpectation = expectation(description: "Prefill completes") - controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) { error in + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0, groupHandler: { key, _ in key })) { error in XCTAssertNil(error) prefillExpectation.fulfill() } @@ -289,7 +289,7 @@ final class ChannelListController_Tests: XCTestCase { ] let prefillExpectation = expectation(description: "Prefill completes") - controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) { error in + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0, groupHandler: { key, _ in key })) { error in XCTAssertNil(error) prefillExpectation.fulfill() } @@ -318,7 +318,7 @@ final class ChannelListController_Tests: XCTestCase { ] let prefillExpectation = expectation(description: "Prefill completes") - controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) { error in + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0, groupHandler: { key, _ in key })) { error in XCTAssertNil(error) prefillExpectation.fulfill() } @@ -351,7 +351,7 @@ final class ChannelListController_Tests: XCTestCase { ] let prefillExpectation = expectation(description: "Prefill completes") - controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) { error in + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0, groupHandler: { key, _ in key })) { error in XCTAssertNil(error) prefillExpectation.fulfill() } @@ -374,7 +374,7 @@ final class ChannelListController_Tests: XCTestCase { ] let prefillExpectation = expectation(description: "Prefill completes") - controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) { error in + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0, groupHandler: { key, _ in key })) { error in XCTAssertNil(error) prefillExpectation.fulfill() } @@ -397,7 +397,7 @@ final class ChannelListController_Tests: XCTestCase { ] let prefillExpectation = expectation(description: "Prefill completes") - controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) { error in + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0, groupHandler: { key, _ in key })) { error in XCTAssertNil(error) prefillExpectation.fulfill() } @@ -421,7 +421,7 @@ final class ChannelListController_Tests: XCTestCase { ] let prefillExpectation = expectation(description: "Prefill completes") - controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) { error in + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0, groupHandler: { key, _ in key })) { error in XCTAssertNil(error) prefillExpectation.fulfill() } @@ -464,7 +464,7 @@ final class ChannelListController_Tests: XCTestCase { } let prefillExpectation = expectation(description: "Prefill completes") - controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: [makePrefilledChannel(cid: replacementCid)], unreadChannels: 0)) { error in + controller.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: [makePrefilledChannel(cid: replacementCid)], unreadChannels: 0, groupHandler: { key, _ in key })) { error in XCTAssertNil(error) prefillExpectation.fulfill() } diff --git a/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift b/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift index 2d6b6fb1334..c9af62887cc 100644 --- a/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift +++ b/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift @@ -242,8 +242,8 @@ class SyncRepository_Tests: XCTestCase { 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])) + let refreshedGroup = GroupedChannelsGroup(groupKey: "all", channels: [.mock(cid: cid)], unreadChannels: 0, groupHandler: { key, _ in key }) + channelListUpdater.queryGroupedChannels_result = .success(.init(groups: ["all": refreshedGroup], groupHandler: { key, _ in key })) waitForSyncLocalStateRun() @@ -278,8 +278,8 @@ class SyncRepository_Tests: XCTestCase { 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])) + let refreshedGroup = GroupedChannelsGroup(groupKey: "current", channels: [.mock(cid: prefilledCid)], unreadChannels: 0, groupHandler: { key, _ in key }) + channelListUpdater.queryGroupedChannels_result = .success(.init(groups: ["current": refreshedGroup], groupHandler: { key, _ in key })) waitForSyncLocalStateRun() @@ -324,8 +324,8 @@ class SyncRepository_Tests: XCTestCase { prefilledQuery.groupKey = "all" let channelList = ChannelList_Mock.mock(query: prefilledQuery, client: client) repository.startTrackingChannelList(channelList) - let refreshedGroup = GroupedChannelsGroup(groupKey: "all", channels: [.mock(cid: cid)], unreadChannels: 0) - channelListUpdater.queryGroupedChannels_result = .success(.init(groups: ["all": refreshedGroup])) + let refreshedGroup = GroupedChannelsGroup(groupKey: "all", channels: [.mock(cid: cid)], unreadChannels: 0, groupHandler: { key, _ in key }) + channelListUpdater.queryGroupedChannels_result = .success(.init(groups: ["all": refreshedGroup], groupHandler: { key, _ in key })) waitForSyncLocalStateRun() @@ -353,8 +353,8 @@ class SyncRepository_Tests: XCTestCase { standardChannelList.refreshLoadedChannelsResult = .success(Set([standardCid])) repository.startTrackingChannelList(standardChannelList) - let refreshedGroup = GroupedChannelsGroup(groupKey: "current", channels: [.mock(cid: prefilledCid)], unreadChannels: 0) - channelListUpdater.queryGroupedChannels_result = .success(.init(groups: ["current": refreshedGroup])) + let refreshedGroup = GroupedChannelsGroup(groupKey: "current", channels: [.mock(cid: prefilledCid)], unreadChannels: 0, groupHandler: { key, _ in key }) + channelListUpdater.queryGroupedChannels_result = .success(.init(groups: ["current": refreshedGroup], groupHandler: { key, _ in key })) waitForSyncLocalStateRun() diff --git a/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift b/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift index 40b1d92236b..dd820aa7e3c 100644 --- a/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift +++ b/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift @@ -95,7 +95,7 @@ final class ChannelList_Tests: XCTestCase { await setUpChannelList(usesMockedChannelUpdater: false, loadState: false, pageSize: 2) let prefilledChannels = try await makePrefilledChannels(count: 2, createdAtOffset: 0) - try await channelList.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) + try await channelList.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0, groupHandler: { key, _ in key })) try await channelList.get() XCTAssertTrue(env.client.mockAPIClient.request_allRecordedCalls.isEmpty) @@ -103,21 +103,122 @@ final class ChannelList_Tests: XCTestCase { await XCTAssertEqual(prefilledChannels.map(\.cid.rawValue), channelList.state.channels.map(\.cid.rawValue)) } - func test_prefill_loadMoreChannels_usesPrefilledChannelsCountAsOffset() async throws { - await setUpChannelList(usesMockedChannelUpdater: true, loadState: false, pageSize: 2) + func test_prefill_whenNextCursorIsNil_loadMoreChannelsReturnsEmpty() async throws { + await setUpChannelList( + usesMockedChannelUpdater: true, + loadState: false, + pageSize: 2, + groupHandler: { key, _ in key } + ) let prefilledChannels = try await makePrefilledChannels(count: 3, createdAtOffset: 0) - env.channelListUpdaterMock.update_completion_result = .success([]) - - try await channelList.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) + + try await channelList.prefill(group: GroupedChannelsGroup( + groupKey: "all", + channels: prefilledChannels, + unreadChannels: 0, + groupHandler: { key, _ in key } + )) + let loadedChannels = try await channelList.loadMoreChannels(limit: 2) + + XCTAssertEqual([], loadedChannels) + XCTAssertEqual(0, env.channelListUpdaterMock.queryGroupedChannels_callCount) + XCTAssertTrue(env.channelListUpdaterMock.update_queries.isEmpty) + } + + func test_prefill_loadMoreChannels_usesGroupedPaginationWithStoredCursor() async throws { + await setUpChannelList( + usesMockedChannelUpdater: true, + loadState: false, + pageSize: 2, + groupHandler: { key, _ in key } + ) + let prefilledChannels = try await makePrefilledChannels(count: 3, createdAtOffset: 0) + let nextPageChannels = try await makePrefilledChannels(count: 2, createdAtOffset: 3) + let nextPageGroup = GroupedChannelsGroup( + groupKey: "all", + channels: nextPageChannels, + unreadChannels: 0, + next: "cursor-2", + groupHandler: { key, _ in key } + ) + env.channelListUpdaterMock.queryGroupedChannels_result = .success( + GroupedChannels(groups: ["all": nextPageGroup], groupHandler: { key, _ in key }) + ) + env.channelListUpdaterMock.appendToQuery_result = .success(nextPageChannels) + + try await channelList.prefill(group: GroupedChannelsGroup( + groupKey: "all", + channels: prefilledChannels, + unreadChannels: 0, + next: "cursor-1", + groupHandler: { key, _ in key } + )) + let returned = try await channelList.loadMoreChannels(limit: 2) + + XCTAssertEqual(1, env.channelListUpdaterMock.queryGroupedChannels_callCount) + XCTAssertEqual(2, env.channelListUpdaterMock.queryGroupedChannels_limits.first ?? nil) + let pagination = env.channelListUpdaterMock.queryGroupedChannels_paginations.first ?? nil + XCTAssertEqual("all", pagination?.groupKey) + XCTAssertEqual("cursor-1", pagination?.next) + XCTAssertNil(pagination?.prev) + XCTAssertEqual(nextPageChannels.map(\.cid.rawValue), returned.map(\.cid.rawValue)) + let cursorAfter = await channelList.state.groupPaginationCursor + XCTAssertEqual("cursor-2", cursorAfter) + let fullyLoaded = await channelList.state.hasLoadedAllPreviousChannels + XCTAssertFalse(fullyLoaded) + } + + func test_prefill_loadMoreChannels_whenResponseHasNoNextCursor_marksAsFullyLoaded() async throws { + await setUpChannelList( + usesMockedChannelUpdater: true, + loadState: false, + pageSize: 2, + groupHandler: { key, _ in key } + ) + let prefilledChannels = try await makePrefilledChannels(count: 3, createdAtOffset: 0) + let nextPageChannels = try await makePrefilledChannels(count: 2, createdAtOffset: 3) + let nextPageGroup = GroupedChannelsGroup( + groupKey: "all", + channels: nextPageChannels, + unreadChannels: 0, + next: nil, + groupHandler: { key, _ in key } + ) + env.channelListUpdaterMock.queryGroupedChannels_result = .success( + GroupedChannels(groups: ["all": nextPageGroup], groupHandler: { key, _ in key }) + ) + env.channelListUpdaterMock.appendToQuery_result = .success(nextPageChannels) + + try await channelList.prefill(group: GroupedChannelsGroup( + groupKey: "all", + channels: prefilledChannels, + unreadChannels: 0, + next: "cursor-1", + groupHandler: { key, _ in key } + )) _ = try await channelList.loadMoreChannels(limit: 2) - - XCTAssertEqual(env.channelListUpdaterMock.update_queries.last?.pagination, .init(pageSize: 2, offset: 3)) + + let cursorAfter = await channelList.state.groupPaginationCursor + XCTAssertNil(cursorAfter) + let fullyLoaded = await channelList.state.hasLoadedAllPreviousChannels + XCTAssertTrue(fullyLoaded) + } + + func test_loadMoreChannels_withoutGroupHandler_usesOffsetPath() async throws { + await setUpChannelList(usesMockedChannelUpdater: true, pageSize: 2) + let responseChannels = makeChannels(count: 2, createdAtOffset: 0) + env.channelListUpdaterMock.update_completion_result = .success(responseChannels) + + _ = try await channelList.loadMoreChannels(limit: 2) + + XCTAssertEqual(0, env.channelListUpdaterMock.queryGroupedChannels_callCount) + XCTAssertEqual(1, env.channelListUpdaterMock.update_queries.count) } func test_prefill_whenNoChannelsWereSaved_loadMoreChannelsDoesNotRequestNextPage() async throws { await setUpChannelList(usesMockedChannelUpdater: true, loadState: false, pageSize: 2) - try await channelList.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: [], unreadChannels: 0)) + try await channelList.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: [], unreadChannels: 0, groupHandler: { key, _ in key })) let loadedChannels = try await channelList.loadMoreChannels(limit: 2) XCTAssertEqual([], loadedChannels) @@ -128,7 +229,7 @@ final class ChannelList_Tests: XCTestCase { await setUpChannelList(usesMockedChannelUpdater: false, loadState: false, pageSize: 2) let prefilledChannels = try await makePrefilledChannels(count: 3, createdAtOffset: 0) - try await channelList.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) + try await channelList.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0, groupHandler: { key, _ in key })) await XCTAssertEqual(prefilledChannels.map(\.cid.rawValue), channelList.state.channels.map(\.cid.rawValue)) } @@ -138,7 +239,7 @@ final class ChannelList_Tests: XCTestCase { await XCTAssertEqual(0, channelList.state.channels.count) let prefilledChannels = try await makePrefilledChannels(count: 3, createdAtOffset: 0) - try await channelList.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) + try await channelList.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0, groupHandler: { key, _ in key })) try await channelList.get() XCTAssertTrue(env.client.mockAPIClient.request_allRecordedCalls.isEmpty) @@ -150,7 +251,7 @@ final class ChannelList_Tests: XCTestCase { await XCTAssertEqual(0, channelList.state.channels.count) let prefilledChannels = try await makePrefilledChannels(count: 3, createdAtOffset: 0) - try await channelList.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0)) + try await channelList.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0, groupHandler: { key, _ in key })) try await channelList.get() XCTAssertTrue(env.client.mockAPIClient.request_allRecordedCalls.isEmpty) @@ -176,7 +277,8 @@ final class ChannelList_Tests: XCTestCase { let prefilledGroup = GroupedChannelsGroup( groupKey: "all", channels: [replacementChannel], - unreadChannels: 0 + unreadChannels: 0, + groupHandler: { key, _ in key } ) try await channelList.prefill(group: prefilledGroup) @@ -629,7 +731,8 @@ final class ChannelList_Tests: XCTestCase { filter: Filter? = nil, pageSize: Int = .channelsPageSize, sort: [Sorting] = [.init(key: .createdAt, isAscending: true)], - dynamicFilter: (@Sendable (ChatChannel) -> Bool)? = nil + dynamicFilter: (@Sendable (ChatChannel) -> Bool)? = nil, + groupHandler: (@Sendable (String, ChatChannel) -> String)? = nil ) { channelList = ChannelList( query: ChannelListQuery( @@ -638,6 +741,7 @@ final class ChannelList_Tests: XCTestCase { pageSize: pageSize ), dynamicFilter: dynamicFilter, + groupHandler: groupHandler, client: env.client, environment: env.channelListEnvironment(usesMockedUpdater: usesMockedChannelUpdater) ) diff --git a/Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift b/Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift index 715de9603a0..47829667265 100644 --- a/Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift +++ b/Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift @@ -437,6 +437,122 @@ final class ChannelListUpdater_Tests: XCTestCase { XCTAssertFalse(channelsInQuery.contains(channel)) } + // MARK: - queryGroupedChannels + + func test_queryGroupedChannels_initial_sendsBodyWithoutGroupsKey() throws { + listUpdater.queryGroupedChannels(limit: 10, groupHandler: { key, _ in key }, completion: { _ in }) + + let body = try XCTUnwrap(apiClient.request_endpoint?.bodyAsDictionary()) + XCTAssertEqual(10, body["limit"] as? Int) + XCTAssertNil(body["groups"]) + } + + func test_queryGroupedChannels_paginated_sendsBodyWithGroupsKeyAndCursor() throws { + let pagination = GroupChannelsPagination(groupKey: "old", next: "old-cursor", prev: nil) + listUpdater.queryGroupedChannels( + limit: 5, + pagination: pagination, + groupHandler: { key, _ in key }, + completion: { _ in } + ) + + let body = try XCTUnwrap(apiClient.request_endpoint?.bodyAsDictionary()) + XCTAssertNil(body["limit"], "top-level limit must be omitted when paginating") + let groups = try XCTUnwrap(body["groups"] as? [String: [String: Any]]) + XCTAssertEqual(["old"], groups.keys.sorted()) + XCTAssertEqual(5, groups["old"]?["limit"] as? Int) + XCTAssertEqual("old-cursor", groups["old"]?["next"] as? String) + XCTAssertNil(groups["old"]?["prev"]) + } + + func test_queryGroupedChannels_response_populatesNextAndPrevOnGroup() throws { + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .user)) + } + nonisolated(unsafe) var completionResult: Result? + let exp = expectation(description: "completion called") + listUpdater.queryGroupedChannels(groupHandler: { key, _ in key }) { result in + completionResult = result + exp.fulfill() + } + + let groupPayload = GroupedQueryChannelsGroupPayload( + channels: [], + unreadChannels: 3, + next: "next-cursor", + prev: "prev-cursor" + ) + let payload = GroupedQueryChannelsPayload(groups: ["current": groupPayload], duration: "1ms") + apiClient.test_simulateResponse(.success(payload)) + + waitForExpectations(timeout: defaultTimeout) + let group = try completionResult?.get().groups["current"] + XCTAssertEqual("next-cursor", group?.next) + XCTAssertEqual("prev-cursor", group?.prev) + } + + func test_queryGroupedChannels_paginated_doesNotOverwriteGroupedUnreadChannels() throws { + // Seed current user with unread counts for multiple groups. + let userId = UserId.unique + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: .dummy(userId: userId, role: .user)) + try session.saveCurrentUserGroupedUnreadChannels(["new": 5, "current": 10, "old": 2]) + } + + let pagination = GroupChannelsPagination(groupKey: "old", next: "cursor", prev: nil) + nonisolated(unsafe) var completionCalled = false + listUpdater.queryGroupedChannels( + pagination: pagination, + groupHandler: { key, _ in key } + ) { _ in + completionCalled = true + } + + // Paginated response carries only "old" group. + let payload = GroupedQueryChannelsPayload( + groups: ["old": .init(channels: [], unreadChannels: 99)], + duration: "1ms" + ) + apiClient.test_simulateResponse(.success(payload)) + + AssertAsync.willBeTrue(completionCalled) + + // Other groups' counters must remain intact (would be clobbered if mapValues ran). + let counters = database.viewContext.currentUser?.groupedUnreadChannels ?? [:] + XCTAssertEqual(5, counters["new"]) + XCTAssertEqual(10, counters["current"]) + XCTAssertEqual(2, counters["old"]) + } + + func test_appendToQuery_linksNewChannelsWithoutRemovingExisting() throws { + var query = ChannelListQuery(filter: .nonEmpty) + query.groupKey = "all" + let existingCid = ChannelId(type: .messaging, id: .unique) + let newCid = ChannelId(type: .messaging, id: .unique) + nonisolated(unsafe) var newChannel: ChatChannel? + try database.writeSynchronously { session in + let existingDTO = try session.saveChannel(payload: self.dummyPayload(with: existingCid)) + let queryDTO = session.saveQuery(query: query) + queryDTO.channels.insert(existingDTO) + let newDTO = try session.saveChannel(payload: self.dummyPayload(with: newCid)) + newChannel = try newDTO.asModel() + } + + let group = GroupedChannelsGroup( + groupKey: "all", + channels: [try XCTUnwrap(newChannel)], + unreadChannels: 0, + groupHandler: { key, _ in key } + ) + let exp = expectation(description: "append completion") + listUpdater.appendToQuery(group: group, for: query) { _ in exp.fulfill() } + waitForExpectations(timeout: defaultTimeout) + + let queryDTO = try XCTUnwrap(database.viewContext.channelListQuery(query)) + let linkedCids = Set(queryDTO.channels.map(\.cid)) + XCTAssertEqual(Set([existingCid.rawValue, newCid.rawValue]), linkedCids) + } + private func channels(for query: ChannelListQuery, database: DatabaseContainer) -> Set { let request = NSFetchRequest(entityName: ChannelListQueryDTO.entityName) request.predicate = NSPredicate(format: "filterHash == %@", query.filter.filterHash) From a3c62485c0e5ef0fbd0773ea2741e9c5032f0347 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 14 May 2026 15:51:21 +0300 Subject: [PATCH 30/31] Paginate using grouped channels endpoint and add handling for WS events propagating group --- CHANGELOG.md | 8 +- Sources/StreamChat/ChatClient.swift | 12 +- .../ChannelListController.swift | 129 +++---- .../Database/DTOs/ChannelListQueryDTO.swift | 5 + .../StreamChatModel.xcdatamodel/contents | 1 + .../StreamChat/Models/GroupedChannels.swift | 15 +- .../StreamChat/Query/ChannelListQuery.swift | 5 + .../Repositories/SyncOperations.swift | 65 +--- .../Repositories/SyncRepository.swift | 35 +- .../StreamChat/StateLayer/ChannelList.swift | 106 ++---- .../StateLayer/ChannelListState.swift | 36 +- .../StateLayer/ChatClient+Factory.swift | 17 +- .../Events/ChannelEvents.swift | 28 +- .../WebSocketClient/Events/EventPayload.swift | 5 + .../Events/MessageEvents.swift | 11 +- .../Events/NotificationEvents.swift | 27 +- .../Workers/ChannelListLinker.swift | 97 ++--- .../Workers/ChannelListUpdater.swift | 213 ++++------- .../ChatChannelListController_Mock.swift | 9 - .../StreamChat/State/ChannelList_Mock.swift | 11 +- .../Spy/ChannelListUpdater_Spy.swift | 77 ++-- Tests/StreamChatTests/ChatClient_Tests.swift | 5 +- .../ChannelListController_Tests.swift | 340 ++++-------------- .../Repositories/SyncRepository_Tests.swift | 73 ++-- .../StateLayer/ChannelList_Tests.swift | 225 +++--------- .../Workers/ChannelListUpdater_Tests.swift | 130 +++++-- 26 files changed, 607 insertions(+), 1078 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa61599b8c7..de3823f0f8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## StreamChat ### ✅ Added -- Add `ChannelList.prefill(group:)` for priming state-layer channel data [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076) -- Add `ChatChannelListController.prefill(group:completion:)` for priming controller-local channel data [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076) -- Add `ChatClient.queryGroupedChannels(limit:watch:presence:)` to fetch grouped channels with per group unread counts [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076) -- Add optional `groupedUnreadChannels` data to relevant web-socket events and to `CurrentChatUser` [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076) +- Add `ChatClient.queryGroupedChannels(limit:presence:watch:)` to fetch grouped channels with per-group unread counts [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076) +- Add `ChatClient.channelListController(groupKey:dynamicFilter:)` for observing a single grouped channels group [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076) +- Add `ChatClient.makeChannelList(with:)` overload for observing a single grouped channels group in the state layer [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076) +- Add optional `groupedUnreadChannels` data to relevant web-socket events and `CurrentChatUser` [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076) ### 🐞 Fixed - Fix image cache misses caused by non-deterministic caching keys in `StreamCDNRequester` [#4075](https://github.com/GetStream/stream-chat-swift/pull/4075) - Fix `ChatChannel.latestMessages` being wiped on mid-page pagination [#4077](https://github.com/GetStream/stream-chat-swift/pull/4077) diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index 87279b37e83..ddc582cb975 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -656,27 +656,25 @@ public class ChatClient: @unchecked Sendable { limit: Int? = nil, presence: Bool = false, watch: Bool = false, - groupHandler: @escaping @Sendable (String, ChatChannel) -> String, - completion: @escaping @MainActor (Result) -> Void + completion: @escaping @Sendable (Result) -> Void ) { channelListUpdater.queryGroupedChannels( + groupPagination: nil, limit: limit, watch: watch, presence: presence, - groupHandler: groupHandler, completion: completion ) } /// Queries grouped channel groups for the app. - public func queryGroupedChannels( + @discardableResult public func queryGroupedChannels( limit: Int? = nil, presence: Bool = false, - watch: Bool = false, - groupHandler: @escaping @Sendable (String, ChatChannel) -> String + watch: Bool = false ) async throws -> GroupedChannels { try await withCheckedThrowingContinuation { continuation in - queryGroupedChannels(limit: limit, presence: presence, watch: watch, groupHandler: groupHandler) { result in + queryGroupedChannels(limit: limit, presence: presence, watch: watch) { result in continuation.resume(with: result) } } diff --git a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift index 9f2eaef87b4..f060604e833 100644 --- a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift +++ b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift @@ -28,6 +28,25 @@ extension ChatClient { ) -> ChatChannelListController { .init(query: query, client: self, filter: filter) } + + /// Creates a new `ChannelListController` for a single grouped channels group identified by `groupKey`. + /// + /// - Parameters: + /// - groupKey: The group key returned by `queryGroupedChannels` (e.g. `"all"`, `"new"`, + /// `"current"`) used to identify the underlying `ChannelListQueryDTO`. + /// - dynamicFilter: A predicate consulted by ``ChannelListLinker`` when channel-related + /// web-socket events arrive (`message.new`, `notification.added_to_channel`, + /// `notification.message_new`, `channel.updated`, `channel.visible`). Returning `true` + /// keeps/links the channel in this list's `ChannelListQueryDTO`; returning `false` + /// unlinks it. Required so events for channels in other groups don't leak into this + /// list when automatic filtering is enabled. + /// - Returns: A new instance of `ChatChannelListController`. + public func channelListController( + groupKey: String, + dynamicFilter: @escaping @Sendable (ChatChannel) -> Bool + ) -> ChatChannelListController { + .init(query: .init(groupKey: groupKey), client: self, filter: dynamicFilter) + } } /// `ChatChannelListController` is a controller class which allows observing a list of chat channels based on the provided query. @@ -35,7 +54,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, @unchecked Sendable { /// The query specifying and filtering the list of channels. - public private(set) var query: ChannelListQuery + public let query: ChannelListQuery /// The `ChatClient` instance this controller belongs to. public let client: ChatClient @@ -69,7 +88,6 @@ 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() { @@ -162,14 +180,11 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt channelListLinker.start(with: client.eventNotificationCenter) client.syncRepository.startTrackingChannelListController(self) - if shouldSkipInitialRemoteUpdate { - shouldSkipInitialRemoteUpdate = false + if query.groupKey != nil { state = .remoteDataFetched hasLoadedAllPreviousChannels = channels.isEmpty markChannelsAsDeliveredIfNeeded(channels: Array(channels)) - callback { - completion?(nil) - } + callback { completion?(nil) } return } @@ -196,63 +211,51 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt return } - let limit = limit ?? query.pagination.pageSize - var updatedQuery = query - updatedQuery.pagination = Pagination(pageSize: limit, offset: channels.count) - worker.update(channelListQuery: updatedQuery) { result in - switch result { - case let .success(channels): - self.markChannelsAsDeliveredIfNeeded(channels: channels) - self.hasLoadedAllPreviousChannels = channels.count < limit - self.callback { completion?(nil) } - case let .failure(error): - self.callback { completion?(error) } - } - } - } - - /// 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: (@Sendable (Error?) -> Void)? = nil - ) { - // This changes filter hash to use static group key - query.groupKey = group.groupKey - - worker.prefill(group: group, for: query, filter: filter) { [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 - - // Recreate observer + linker so they pick up the new groupKey, mirroring the - // state-layer ChannelListState.Observer.start(observing:) flow. - self.channelListObserver.stopObserving() - self.channelListObserver = self.makeChannelListObserver( - query: self.query, - minimumFetchLimit: savedChannels.count - ) - self.channelListLinker = self.makeChannelListLinker(query: self.query) - self.channelListLinker.start(with: self.client.eventNotificationCenter) - do { - try self.channelListObserver.startObserving() - } catch { - log.error("Failed to restart channel list observer after prefill: \(error)") - } - - self.callback { - completion?(nil) + if let groupKey = query.groupKey { + worker.paginationCursor(for: groupKey) { result in + switch result { + case .failure(let error): + self.callback { completion?(error) } + case .success(let cursor): + guard let cursor else { + self.hasLoadedAllPreviousChannels = true + self.callback { completion?(nil) } + return + } + self.worker.queryGroupedChannels( + groupPagination: .init(groupKey: groupKey, next: cursor, prev: nil), + limit: limit, + watch: true, + presence: true + ) { queryResult in + switch queryResult { + case let .success(grouped): + if let group = grouped.groups[groupKey] { + self.hasLoadedAllPreviousChannels = group.next == nil + self.markChannelsAsDeliveredIfNeeded(channels: group.channels) + } + self.callback { completion?(nil) } + case let .failure(error): + self.callback { completion?(error) } + } + } } - case let .failure(error): - self.callback { - completion?(error) + } + } else { + let limit = limit ?? query.pagination.pageSize + var updatedQuery = query + updatedQuery.pagination = Pagination( + pageSize: limit, + offset: channels.count + ) + worker.update(channelListQuery: updatedQuery) { result in + switch result { + case let .success(channels): + self.markChannelsAsDeliveredIfNeeded(channels: channels) + self.hasLoadedAllPreviousChannels = channels.count < limit + self.callback { completion?(nil) } + case let .failure(error): + self.callback { completion?(error) } } } } diff --git a/Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift index 666f0f93e05..47fb43964f7 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift @@ -12,6 +12,11 @@ class ChannelListQueryDTO: NSManagedObject { /// Serialized `Filter` JSON which can be used in cases the query needs to be repeated, i.e. for newly created channels. @NSManaged var filterJSONData: Data + /// Next-page cursor returned by the grouped channels endpoint for this group. + /// `nil` means there is no next page (either never paginated or the backend + /// signaled exhaustion). Only meaningful for queries that carry a `groupKey`. + @NSManaged var next: String? + // MARK: - Relationships @NSManaged var channels: Set diff --git a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents index 4ba20e26feb..24a830899b2 100644 --- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents +++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents @@ -116,6 +116,7 @@ + diff --git a/Sources/StreamChat/Models/GroupedChannels.swift b/Sources/StreamChat/Models/GroupedChannels.swift index 748f85088a9..75126260121 100644 --- a/Sources/StreamChat/Models/GroupedChannels.swift +++ b/Sources/StreamChat/Models/GroupedChannels.swift @@ -9,15 +9,9 @@ public struct GroupedChannels: Sendable { /// The grouped channel groups returned by the backend, keyed by group name. public let groups: [String: GroupedChannelsGroup] - init( - groups: [String: GroupedChannelsGroup], - groupHandler: @escaping @Sendable (String, ChatChannel) -> String - ) { + init(groups: [String: GroupedChannelsGroup]) { self.groups = groups - self.groupHandler = groupHandler } - - let groupHandler: @Sendable (String, ChatChannel) -> String } /// A grouped channels group returned by `ChatClient.queryGroupedChannels`. @@ -33,15 +27,13 @@ public struct GroupedChannelsGroup: Sendable { let next: String? let prev: String? - let groupHandler: @Sendable (String, ChatChannel) -> String init( groupKey: String, channels: [ChatChannel], unreadChannels: Int, next: String? = nil, - prev: String? = nil, - groupHandler: @escaping @Sendable (String, ChatChannel) -> String + prev: String? = nil ) { self.groupKey = groupKey self.channels = channels @@ -54,11 +46,10 @@ public struct GroupedChannelsGroup: Sendable { self.unreadChannels = max(unreadChannels, derivedUnreadChannels) self.next = next self.prev = prev - self.groupHandler = groupHandler } } -struct GroupChannelsPagination: Sendable { +struct GroupedChannelsPagination: Sendable { let groupKey: String let next: String? let prev: String? diff --git a/Sources/StreamChat/Query/ChannelListQuery.swift b/Sources/StreamChat/Query/ChannelListQuery.swift index 741f934b460..580c9f7bced 100644 --- a/Sources/StreamChat/Query/ChannelListQuery.swift +++ b/Sources/StreamChat/Query/ChannelListQuery.swift @@ -53,6 +53,11 @@ public struct ChannelListQuery: Encodable, Sendable, LocalConvertibleSortingQuer self.membersLimit = membersLimit } + init(groupKey: String) { + self.init(filter: .and([])) + self.groupKey = groupKey + } + public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(filter, forKey: .filter) diff --git a/Sources/StreamChat/Repositories/SyncOperations.swift b/Sources/StreamChat/Repositories/SyncOperations.swift index e0c47eaaac8..afc1c3636a4 100644 --- a/Sources/StreamChat/Repositories/SyncOperations.swift +++ b/Sources/StreamChat/Repositories/SyncOperations.swift @@ -94,11 +94,11 @@ final class RefreshChannelListOperation: AsyncOperation, @unchecked Sendable { done(.continue) return } - guard channelList.query.value.groupKey == nil else { + guard channelList.query.groupKey == nil else { done(.continue) return } - let query = channelList.query.value + let query = channelList.query Task { do { let channelIds = try await channelList.refreshLoadedChannels() @@ -115,34 +115,16 @@ final class RefreshChannelListOperation: AsyncOperation, @unchecked Sendable { } final class SyncGroupedChannelsOperation: AsyncOperation, @unchecked Sendable { - init( - channelListUpdater: ChannelListUpdater, - controllers: [ChatChannelListController], - channelLists: [ChannelList], - context: SyncContext - ) { + init(channelListUpdater: ChannelListUpdater, context: SyncContext) { super.init(maxRetries: syncOperationsMaximumRetries) { [weak channelListUpdater] _, done in - let channelListsAndKeys = channelLists.compactMap { channelList -> (channelList: ChannelList, groupKey: String)? in - guard let groupKey = channelList.query.value.groupKey else { return nil } - return (channelList, groupKey) - } - - guard !controllers.isEmpty || !channelListsAndKeys.isEmpty else { - done(.continue) - return - } - guard let channelListUpdater else { done(.continue) return } - let defaultGroupHandler: @Sendable (String, ChatChannel) -> String = { key, _ in key } - let groupHandler = channelLists.lazy.compactMap(\.groupHandler).first ?? defaultGroupHandler - Task { do { - let groupedChannels = try await channelListUpdater.queryGroupedChannels(groupHandler: groupHandler) + let groupedChannels = try await channelListUpdater.queryGroupedChannels(groupPagination: nil, limit: nil, watch: true, presence: true) let returnedChannelIds = groupedChannels.groups.values .flatMap(\.channels) .map(\.cid) @@ -151,44 +133,7 @@ final class SyncGroupedChannelsOperation: AsyncOperation, @unchecked Sendable { "Synced \(returnedChannelIds.count) grouped channels across \(groupedChannels.groups.count) group(s)", subsystems: .offlineSupport ) - - // Forward each returned group to matching prefilled lists so 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) { @Sendable error in - if let error { - log.error( - "Failed to prefill controller for group \(key): \(error)", - subsystems: .offlineSupport - ) - } - dispatchGroup.leave() - } - } - for (channelList, key) in channelListsAndKeys { - guard let group = groupedChannels.groups[key] else { continue } - dispatchGroup.enter() - Task { - do { - try await channelList.prefill(group: group) - } catch { - log.error( - "Failed to prefill channel list for group \(key): \(error)", - subsystems: .offlineSupport - ) - } - dispatchGroup.leave() - } - } - dispatchGroup.notify(queue: .global(qos: .utility)) { @Sendable in - done(.continue) - } + done(.continue) } catch { log.error("Failed to refresh grouped channels during sync: \(error)", subsystems: .offlineSupport) done(.retry) diff --git a/Sources/StreamChat/Repositories/SyncRepository.swift b/Sources/StreamChat/Repositories/SyncRepository.swift index 3bc96f257c0..a2cad9d2a2a 100644 --- a/Sources/StreamChat/Repositories/SyncRepository.swift +++ b/Sources/StreamChat/Repositories/SyncRepository.swift @@ -160,8 +160,8 @@ class SyncRepository: @unchecked Sendable { /// /// 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`, non-prefilled `ChatChannelListController`) - /// 2.5 Refresh the shared grouped channels response when any prefilled `ChatChannelListController` is active + /// 2. Refresh channel lists (channels for current pages in `ChannelList`, non-grouped `ChatChannelListController`) + /// 2.5 Refresh the shared grouped channels response when any grouped `ChatChannelListController` or `ChannelList` 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 @@ -191,24 +191,23 @@ class SyncRepository: @unchecked Sendable { /// 1. Collect all the **active** channel ids operations.append(ActiveChannelIdsOperation(syncRepository: self, context: context)) - // 2. Refresh channel lists + // 2. Refresh standard (non-grouped) channel lists let allChannelLists = activeChannelLists.allObjects - let prefilledChannelLists = allChannelLists.filter { $0.query.value.groupKey != nil } - let standardChannelLists = allChannelLists.filter { $0.query.value.groupKey == nil } - operations.append(contentsOf: standardChannelLists.map { RefreshChannelListOperation(channelList: $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 lists populated via `prefill(...)`) - if !prefilledControllers.isEmpty || !prefilledChannelLists.isEmpty { - operations.append(SyncGroupedChannelsOperation( - channelListUpdater: channelListUpdater, - controllers: prefilledControllers, - channelLists: prefilledChannelLists, - context: context - )) + operations.append(contentsOf: allChannelLists + .filter { $0.query.groupKey == nil } + .map { RefreshChannelListOperation(channelList: $0, context: context) } + ) + operations.append(contentsOf: allControllers + .filter { $0.query.groupKey == nil } + .map { RefreshChannelListOperation(controller: $0, context: context) } + ) + + // 2.5 Refresh grouped channels (lists / controllers identified by groupKey) + let hasGroupedLists = allChannelLists.contains { $0.query.groupKey != nil } + let hasGroupedControllers = allControllers.contains { $0.query.groupKey != nil } + if hasGroupedLists || hasGroupedControllers { + operations.append(SyncGroupedChannelsOperation(channelListUpdater: channelListUpdater, context: context)) } // 3. /sync (for channels what not part of active channel lists) diff --git a/Sources/StreamChat/StateLayer/ChannelList.swift b/Sources/StreamChat/StateLayer/ChannelList.swift index f8add31e587..0eb51433bc7 100644 --- a/Sources/StreamChat/StateLayer/ChannelList.swift +++ b/Sources/StreamChat/StateLayer/ChannelList.swift @@ -8,22 +8,17 @@ import Foundation public class ChannelList: @unchecked Sendable { private let channelListUpdater: ChannelListUpdater private let client: ChatClient - private let dynamicFilter: (@Sendable (ChatChannel) -> Bool)? - let groupHandler: (@Sendable (String, ChatChannel) -> String)? - let query: AllocatedUnfairLock + let query: ChannelListQuery @MainActor private var stateBuilder: StateBuilder - + init( query: ChannelListQuery, dynamicFilter: (@Sendable (ChatChannel) -> Bool)?, - groupHandler: (@Sendable (String, ChatChannel) -> String)?, client: ChatClient, environment: Environment = .init() ) { self.client = client - self.dynamicFilter = dynamicFilter - self.groupHandler = groupHandler - self.query = AllocatedUnfairLock(query) + self.query = query let channelListUpdater = environment.channelListUpdater( client.databaseContainer, client.apiClient @@ -53,36 +48,15 @@ public class ChannelList: @unchecked Sendable { /// /// - Throws: An error while communicating with the Stream API. public func get() async throws { - if await state.consumeShouldSkipInitialRemoteUpdate() { - return + if query.groupKey == nil { + let pagination = Pagination(pageSize: query.pagination.pageSize) + try await loadChannels(with: pagination) } - let pagination = Pagination(pageSize: query.value.pagination.pageSize) - try await loadChannels(with: pagination) client.syncRepository.startTrackingChannelList(self) } - /// Prefills the channel list with an initial channel list snapshot and skips the first remote - /// `queryChannels` request when ``get()`` is called afterwards. - /// - /// The prefetched channels are persisted in the local storage and linked only to this channel - /// list query, so pagination, local observation and offline refresh keep working. - public func prefill(group: GroupedChannelsGroup) async throws { - let updatedQuery = query.withLock { - $0.groupKey = group.groupKey - return $0 - } - - let savedChannels = try await channelListUpdater.prefill(group: group, for: updatedQuery, filter: dynamicFilter) - await resetStateAfterPrefill( - query: updatedQuery, - prefilledChannelsCount: savedChannels.count, - next: group.next - ) - client.syncRepository.startTrackingChannelList(self) - } - // MARK: - Channel List Pagination - + /// Loads channels for the specified pagination parameters and updates ``ChannelListState/channels``. /// /// - Important: If the pagination offset is 0 and cursor is nil, then loaded channels are reset. @@ -92,9 +66,21 @@ public class ChannelList: @unchecked Sendable { /// - Throws: An error while communicating with the Stream API. /// - Returns: An array of channels for the pagination. @discardableResult public func loadChannels(with pagination: Pagination) async throws -> [ChatChannel] { - return try await channelListUpdater.loadChannels(query: query.value, pagination: pagination) + if let groupKey = query.groupKey { + let groupedChannels = try await channelListUpdater.queryGroupedChannels( + groupPagination: .init(groupKey: groupKey, next: pagination.cursor, prev: nil), + limit: pagination.pageSize, + watch: true, + presence: true + ) + let group = groupedChannels.groups[groupKey] + await setHasLoadedAllPreviousChannels(group?.next == nil) + return group?.channels ?? [] + } else { + return try await channelListUpdater.loadChannels(query: query, pagination: pagination) + } } - + /// Loads more channels and updates ``ChannelListState/channels``. /// /// - Parameter limit: The limit for the page size. The default limit is 20. @@ -102,55 +88,29 @@ public class ChannelList: @unchecked Sendable { /// - Throws: An error while communicating with the Stream API. /// - Returns: An array of loaded channels. @discardableResult public func loadMoreChannels(limit: Int? = nil) async throws -> [ChatChannel] { - guard await state.hasLoadedAllPreviousChannels == false else { return [] } - let query = query.value + guard await !state.hasLoadedAllPreviousChannels else { return [] } let limit = limit ?? query.pagination.pageSize - if let groupKey = query.groupKey, let groupHandler = groupHandler { - return try await loadMoreGroupedChannels(groupKey: groupKey, groupHandler: groupHandler, query: query, limit: limit) + if let groupKey = query.groupKey { + let cursor = try await channelListUpdater.paginationCursor(for: groupKey) + guard let cursor else { + await setHasLoadedAllPreviousChannels(true) + return [] + } + return try await loadChannels(with: Pagination(pageSize: limit, cursor: cursor)) + } else { + let channels = try await loadChannels(with: Pagination(pageSize: limit, offset: await state.channels.count)) + await setHasLoadedAllPreviousChannels(channels.count < limit) + return channels } - let count = await state.channels.count - let loadedChannels = try await channelListUpdater.loadNextChannels( - query: query, - limit: limit, - loadedChannelsCount: count - ) - await setHasLoadedAllPreviousChannels(loadedChannels.count < limit) - return loadedChannels } // MARK: - Internal func refreshLoadedChannels() async throws -> Set { - let query = query.value let count = await state.channels.count return try await channelListUpdater.refreshLoadedChannels(for: query, channelCount: count) } - private func loadMoreGroupedChannels( - groupKey: String, - groupHandler: @escaping @Sendable (String, ChatChannel) -> String, - query: ChannelListQuery, - limit: Int - ) async throws -> [ChatChannel] { - let cursor = await state.groupPaginationCursor - let pagination = GroupChannelsPagination(groupKey: groupKey, next: cursor, prev: nil) - let result = try await channelListUpdater.queryGroupedChannels( - limit: limit, - pagination: pagination, - groupHandler: groupHandler - ) - guard let newGroup = result.groups[groupKey] else { return [] } - let appended = try await channelListUpdater.appendToQuery(group: newGroup, for: query, filter: dynamicFilter) - await state.setGroupPaginationCursor(newGroup.next) - await setHasLoadedAllPreviousChannels(newGroup.next == nil) - return appended - } - - @MainActor private func resetStateAfterPrefill(query: ChannelListQuery, prefilledChannelsCount: Int, next: String?) { - state.reset(to: query, prefilledCount: prefilledChannelsCount, next: next) - state.skipNextInitialRemoteUpdate() - } - @MainActor private func setHasLoadedAllPreviousChannels(_ hasLoadedAllPreviousChannels: Bool) { state.hasLoadedAllPreviousChannels = hasLoadedAllPreviousChannels } diff --git a/Sources/StreamChat/StateLayer/ChannelListState.swift b/Sources/StreamChat/StateLayer/ChannelListState.swift index f2f9228efaf..772d4bb44f0 100644 --- a/Sources/StreamChat/StateLayer/ChannelListState.swift +++ b/Sources/StreamChat/StateLayer/ChannelListState.swift @@ -8,11 +8,10 @@ import Foundation /// Represents a list of channels matching to the specified query. @MainActor public final class ChannelListState: ObservableObject { private let observer: Observer - private var shouldSkipInitialRemoteUpdate = false private var handlers: Observer.Handlers { .init(channelsDidChange: { [weak self] in self?.channels = $0 }) } - + init( query: ChannelListQuery, dynamicFilter: (@Sendable (ChatChannel) -> Bool)?, @@ -33,42 +32,13 @@ import Foundation ) channels = observer.start(observing: query, handlers: handlers) } - + /// The query used for filtering the list of channels. public private(set) var query: ChannelListQuery - + /// A Boolean value that returns whether pagination is finished. var hasLoadedAllPreviousChannels = false - /// The next-page cursor for the prefilled group, used to paginate via the grouped endpoint. - var groupPaginationCursor: String? - /// An array of channels for the specified ``ChannelListQuery``. @Published public internal(set) var channels: [ChatChannel] = [] - - // MARK: - Internal - - func skipNextInitialRemoteUpdate() { - shouldSkipInitialRemoteUpdate = true - } - - func consumeShouldSkipInitialRemoteUpdate() -> Bool { - defer { shouldSkipInitialRemoteUpdate = false } - return shouldSkipInitialRemoteUpdate - } - - func reset(to query: ChannelListQuery, prefilledCount: Int, next: String?) { - hasLoadedAllPreviousChannels = next == nil - groupPaginationCursor = next - self.query = query - channels = observer.start( - observing: query, - minimumFetchLimit: prefilledCount, - handlers: handlers - ) - } - - func setGroupPaginationCursor(_ cursor: String?) { - groupPaginationCursor = cursor - } } diff --git a/Sources/StreamChat/StateLayer/ChatClient+Factory.swift b/Sources/StreamChat/StateLayer/ChatClient+Factory.swift index f599382a612..f8b8ca50858 100644 --- a/Sources/StreamChat/StateLayer/ChatClient+Factory.swift +++ b/Sources/StreamChat/StateLayer/ChatClient+Factory.swift @@ -49,13 +49,18 @@ extension ChatClient { with query: ChannelListQuery, dynamicFilter: (@Sendable (ChatChannel) -> Bool)? = nil ) -> ChannelList { - ChannelList(query: query, dynamicFilter: dynamicFilter, groupHandler: nil, client: self) + ChannelList(query: query, dynamicFilter: dynamicFilter, client: self) } - - public func makeChannelList(with group: GroupedChannelsGroup) -> ChannelList { - var query = ChannelListQuery(filter: .and([])) - query.groupKey = group.groupKey - return ChannelList(query: query, dynamicFilter: nil, groupHandler: group.groupHandler, client: self) + + /// Creates an instance of ``ChannelList`` which represents an array of channels matching to the specified group. + /// + /// - Note: The initial state for the group must be fetched with ``ChatClient/queryGroupedChannels(limit:presence:watch:)``. + public func makeChannelList(with groupKey: String) -> ChannelList { + ChannelList( + query: .init(groupKey: groupKey), + dynamicFilter: nil, + client: self + ) } } diff --git a/Sources/StreamChat/WebSocketClient/Events/ChannelEvents.swift b/Sources/StreamChat/WebSocketClient/Events/ChannelEvents.swift index 3fb6ad7a11b..9bde9bb66ec 100644 --- a/Sources/StreamChat/WebSocketClient/Events/ChannelEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/ChannelEvents.swift @@ -20,12 +20,15 @@ public final class ChannelUpdatedEvent: ChannelSpecificEvent { /// The event timestamp. public let createdAt: Date + + let channelCustom: ChannelCustom? - init(channel: ChatChannel, user: ChatUser?, message: ChatMessage?, createdAt: Date) { + init(channel: ChatChannel, user: ChatUser?, message: ChatMessage?, createdAt: Date, channelCustom: ChannelCustom? = nil) { self.channel = channel self.user = user self.message = message self.createdAt = createdAt + self.channelCustom = channelCustom } } @@ -34,6 +37,7 @@ final class ChannelUpdatedEventDTO: EventDTO { let user: UserPayload? let message: MessagePayload? let createdAt: Date + let channelCustom: ChannelCustom? let payload: EventPayload init(from response: EventPayload) throws { @@ -41,6 +45,7 @@ final class ChannelUpdatedEventDTO: EventDTO { user = try? response.value(at: \.user) message = try? response.value(at: \.message) createdAt = try response.value(at: \.createdAt) + channelCustom = try? response.value(at: \.channelCustom) payload = response } @@ -54,7 +59,8 @@ final class ChannelUpdatedEventDTO: EventDTO { channel: channelDTO.asModel(), user: userDTO?.asModel(), message: messageDTO?.asModel(), - createdAt: createdAt + createdAt: createdAt, + channelCustom: channelCustom ) } } @@ -183,10 +189,13 @@ public final class ChannelVisibleEvent: ChannelSpecificEvent { /// The event timestamp. public let createdAt: Date - init(cid: ChannelId, user: ChatUser, createdAt: Date) { + let channelCustom: ChannelCustom? + + init(cid: ChannelId, user: ChatUser, createdAt: Date, channelCustom: ChannelCustom? = nil) { self.cid = cid self.user = user self.createdAt = createdAt + self.channelCustom = channelCustom } } @@ -194,12 +203,14 @@ final class ChannelVisibleEventDTO: EventDTO { let cid: ChannelId let user: UserPayload let createdAt: Date + let channelCustom: ChannelCustom? let payload: EventPayload init(from response: EventPayload) throws { cid = try response.value(at: \.cid) user = try response.value(at: \.user) createdAt = try response.value(at: \.createdAt) + channelCustom = try? response.value(at: \.channelCustom) payload = response } @@ -209,7 +220,8 @@ final class ChannelVisibleEventDTO: EventDTO { return try? ChannelVisibleEvent( cid: cid, user: userDTO.asModel(), - createdAt: createdAt + createdAt: createdAt, + channelCustom: channelCustom ) } } @@ -262,3 +274,11 @@ final class ChannelHiddenEventDTO: EventDTO { ) } } + +final class ChannelCustom: Decodable, Sendable { + let custom: Custom? + + final class Custom: Decodable, Sendable { + let group: String? + } +} diff --git a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift index f7f6d9220a5..af907c650c4 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift @@ -47,6 +47,7 @@ final class EventPayload: Decodable, Sendable { case draft case reminder case channelMessageCount = "channel_message_count" + case channelCustom = "channel_custom" case team } @@ -91,6 +92,7 @@ final class EventPayload: Decodable, Sendable { let draft: DraftPayload? let reminder: ReminderPayload? let channelMessageCount: Int? + let channelCustom: ChannelCustom? let team: TeamId? init( @@ -129,6 +131,7 @@ final class EventPayload: Decodable, Sendable { draft: DraftPayload? = nil, reminder: ReminderPayload? = nil, channelMessageCount: Int? = nil, + channelCustom: ChannelCustom? = nil, deletedForMe: Bool? = nil, lastDeliveredAt: Date? = nil, lastDeliveredMessageId: MessageId? = nil, @@ -169,6 +172,7 @@ final class EventPayload: Decodable, Sendable { self.draft = draft self.reminder = reminder self.channelMessageCount = channelMessageCount + self.channelCustom = channelCustom self.deletedForMe = deletedForMe self.lastDeliveredAt = lastDeliveredAt self.lastDeliveredMessageId = lastDeliveredMessageId @@ -214,6 +218,7 @@ final class EventPayload: Decodable, Sendable { draft = try container.decodeIfPresent(DraftPayload.self, forKey: .draft) reminder = try container.decodeIfPresent(ReminderPayload.self, forKey: .reminder) channelMessageCount = try container.decodeIfPresent(Int.self, forKey: .channelMessageCount) + channelCustom = try container.decodeIfPresent(ChannelCustom.self, forKey: .channelCustom) deletedForMe = try container.decodeIfPresent(Bool.self, forKey: .deletedForMe) lastDeliveredAt = try container.decodeIfPresent(Date.self, forKey: .lastDeliveredAt) lastDeliveredMessageId = try container.decodeIfPresent(MessageId.self, forKey: .lastDeliveredMessageId) diff --git a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift index 0e18304c03d..28b13817413 100644 --- a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift @@ -30,6 +30,8 @@ public final class MessageNewEvent: ChannelSpecificEvent, HasUnreadCount, HasGro /// Grouped unread channel counts keyed by the backend-provided group identifier. public let groupedUnreadChannels: GroupedUnreadChannels? + let channelCustom: ChannelCustom? + init( user: ChatUser, message: ChatMessage, @@ -37,7 +39,8 @@ public final class MessageNewEvent: ChannelSpecificEvent, HasUnreadCount, HasGro createdAt: Date, watcherCount: Int?, unreadCount: UnreadCount?, - groupedUnreadChannels: GroupedUnreadChannels? = nil + groupedUnreadChannels: GroupedUnreadChannels? = nil, + channelCustom: ChannelCustom? = nil ) { self.user = user self.message = message @@ -46,6 +49,7 @@ public final class MessageNewEvent: ChannelSpecificEvent, HasUnreadCount, HasGro self.watcherCount = watcherCount self.unreadCount = unreadCount self.groupedUnreadChannels = groupedUnreadChannels + self.channelCustom = channelCustom } } @@ -56,6 +60,7 @@ final class MessageNewEventDTO: EventDTO { let createdAt: Date let watcherCount: Int? let unreadCount: UnreadCountPayload? + let channelCustom: ChannelCustom? let payload: EventPayload init(from response: EventPayload) throws { @@ -65,6 +70,7 @@ final class MessageNewEventDTO: EventDTO { createdAt = try response.value(at: \.createdAt) watcherCount = try? response.value(at: \.watcherCount) unreadCount = try? response.value(at: \.unreadCount) + channelCustom = try? response.value(at: \.channelCustom) payload = response } @@ -83,7 +89,8 @@ final class MessageNewEventDTO: EventDTO { createdAt: createdAt, watcherCount: watcherCount, unreadCount: UnreadCount(currentUserDTO: currentUser), - groupedUnreadChannels: currentUser.groupedUnreadChannels + groupedUnreadChannels: currentUser.groupedUnreadChannels, + channelCustom: channelCustom ) } } diff --git a/Sources/StreamChat/WebSocketClient/Events/NotificationEvents.swift b/Sources/StreamChat/WebSocketClient/Events/NotificationEvents.swift index 08845daaeb5..d923f7925bd 100644 --- a/Sources/StreamChat/WebSocketClient/Events/NotificationEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/NotificationEvents.swift @@ -24,18 +24,22 @@ public final class NotificationMessageNewEvent: ChannelSpecificEvent, HasUnreadC /// Grouped unread channel counts keyed by the backend-provided group identifier. public let groupedUnreadChannels: GroupedUnreadChannels? + let channelCustom: ChannelCustom? + init( channel: ChatChannel, message: ChatMessage, createdAt: Date, unreadCount: UnreadCount?, - groupedUnreadChannels: GroupedUnreadChannels? = nil + groupedUnreadChannels: GroupedUnreadChannels? = nil, + channelCustom: ChannelCustom? = nil ) { self.channel = channel self.message = message self.createdAt = createdAt self.unreadCount = unreadCount self.groupedUnreadChannels = groupedUnreadChannels + self.channelCustom = channelCustom } } @@ -44,6 +48,7 @@ final class NotificationMessageNewEventDTO: EventDTO { let message: MessagePayload let unreadCount: UnreadCountPayload? let createdAt: Date + let channelCustom: ChannelCustom? let payload: EventPayload init(from response: EventPayload) throws { @@ -51,6 +56,7 @@ final class NotificationMessageNewEventDTO: EventDTO { message = try response.value(at: \.message) createdAt = try response.value(at: \.createdAt) unreadCount = try? response.value(at: \.unreadCount) + channelCustom = try? response.value(at: \.channelCustom) payload = response } @@ -66,7 +72,8 @@ final class NotificationMessageNewEventDTO: EventDTO { message: messageDTO.asModel(), createdAt: createdAt, unreadCount: UnreadCount(currentUserDTO: currentUser), - groupedUnreadChannels: currentUser.groupedUnreadChannels + groupedUnreadChannels: currentUser.groupedUnreadChannels, + channelCustom: channelCustom ) } } @@ -328,11 +335,20 @@ public final class NotificationAddedToChannelEvent: ChannelSpecificEvent, HasUnr /// The event timestamp. public let createdAt: Date - init(channel: ChatChannel, unreadCount: UnreadCount?, member: ChatChannelMember, createdAt: Date) { + let channelCustom: ChannelCustom? + + init( + channel: ChatChannel, + unreadCount: UnreadCount?, + member: ChatChannelMember, + createdAt: Date, + channelCustom: ChannelCustom? = nil + ) { self.channel = channel self.unreadCount = unreadCount self.member = member self.createdAt = createdAt + self.channelCustom = channelCustom } } @@ -342,6 +358,7 @@ final class NotificationAddedToChannelEventDTO: EventDTO { // This `member` field is equal to the `membership` field in channel query let member: MemberPayload let createdAt: Date + let channelCustom: ChannelCustom? let payload: EventPayload init(from response: EventPayload) throws { @@ -349,6 +366,7 @@ final class NotificationAddedToChannelEventDTO: EventDTO { unreadCount = try? response.value(at: \.unreadCount) member = try response.value(at: \.memberContainer?.member) createdAt = try response.value(at: \.createdAt) + channelCustom = try? response.value(at: \.channelCustom) payload = response } @@ -363,7 +381,8 @@ final class NotificationAddedToChannelEventDTO: EventDTO { channel: channelDTO.asModel(), unreadCount: UnreadCount(currentUserDTO: currentUser), member: memberDTO.asModel(), - createdAt: createdAt + createdAt: createdAt, + channelCustom: channelCustom ) } } diff --git a/Sources/StreamChat/Workers/ChannelListLinker.swift b/Sources/StreamChat/Workers/ChannelListLinker.swift index 53222bd5fce..2f22402f762 100644 --- a/Sources/StreamChat/Workers/ChannelListLinker.swift +++ b/Sources/StreamChat/Workers/ChannelListLinker.swift @@ -40,52 +40,62 @@ final class ChannelListLinker: Sendable { eventObservers = [ EventObserver( notificationCenter: nc, - transform: { $0 as? NotificationAddedToChannelEvent } - ) { [weak self] event in self?.linkChannelIfNeeded(event.channel) }, + transform: { $0 as? NotificationAddedToChannelEvent }, + callback: { [weak self] event in + self?.handle(channel: event.channel, allowedActions: [.link], channelCustom: event.channelCustom) + } + ), EventObserver( notificationCenter: nc, transform: { $0 as? MessageNewEvent }, - callback: { [weak self] event in - guard let self else { return } - self.unlinkChannelIfNeeded(event.channel) { - self.linkChannelIfNeeded(event.channel) - } + callback: { [weak self, query] event in + let allowedActions: Set = query.groupKey != nil ? [.link, .unlink] : [.link] + self?.handle(channel: event.channel, allowedActions: allowedActions, channelCustom: event.channelCustom) } ), EventObserver( notificationCenter: nc, transform: { $0 as? NotificationMessageNewEvent }, - callback: { [weak self] event in - guard let self else { return } - self.unlinkChannelIfNeeded(event.channel) { - self.linkChannelIfNeeded(event.channel) - } + callback: { [weak self, query] event in + let allowedActions: Set = query.groupKey != nil ? [.link, .unlink] : [.link] + self?.handle(channel: event.channel, allowedActions: allowedActions, channelCustom: event.channelCustom) } ), EventObserver( notificationCenter: nc, transform: { $0 as? ChannelUpdatedEvent }, callback: { [weak self] event in - guard let self else { return } - self.unlinkChannelIfNeeded(event.channel) { - self.linkChannelIfNeeded(event.channel) - } + self?.handle(channel: event.channel, allowedActions: [.link, .unlink], channelCustom: event.channelCustom) } ), EventObserver( notificationCenter: nc, transform: { $0 as? ChannelVisibleEvent }, callback: { [weak self, databaseContainer] event in + let channelCustom = event.channelCustom let context = databaseContainer.backgroundReadOnlyContext context.perform { [self] in guard let channel = try? context.channel(cid: event.cid)?.asModel() else { return } - self?.linkChannelIfNeeded(channel) + self?.handle(channel: channel, allowedActions: [.link], channelCustom: channelCustom) } } ) ] } + private func handle(channel: ChatChannel, allowedActions: Set, channelCustom: ChannelCustom?) { + let action = linkingAction(for: channel, channelCustom: channelCustom) + + switch action { + case .link where allowedActions.contains(.link): + linkChannel(channel) + case .unlink where allowedActions.contains(.unlink): + unlinkChannel(channel) + default: + break + } + } + private func isInChannelList( _ channel: ChatChannel, completion: @escaping @Sendable (_ isPresent: Bool, _ belongsToOtherQuery: Bool) -> Void @@ -103,9 +113,7 @@ final class ChannelListLinker: Sendable { } } - /// Handles if a channel should be linked to the current query or not. - private func linkChannelIfNeeded(_ channel: ChatChannel) { - guard shouldChannelBelongToCurrentQuery(channel) else { return } + private func linkChannel(_ channel: ChatChannel) { isInChannelList(channel) { [worker, query, channelWatcherHandler] exists, belongsToOtherQuery in guard !exists else { return } worker.link(channel: channel, with: query) { error in @@ -128,35 +136,38 @@ final class ChannelListLinker: Sendable { } } - /// Handles if a channel should be unlinked from the current query or not. - private func unlinkChannelIfNeeded(_ channel: ChatChannel, completion: (@Sendable () -> Void)? = nil) { - guard !shouldChannelBelongToCurrentQuery(channel) else { - completion?() - return - } + private func unlinkChannel(_ channel: ChatChannel) { isInChannelList(channel) { [worker, query] exists, _ in - guard exists else { - completion?() - return - } - worker.unlink(channel: channel, with: query) { _ in - completion?() - } + guard exists else { return } + worker.unlink(channel: channel, with: query) } } /// Checks if the given channel should belong to the current query or not. - private func shouldChannelBelongToCurrentQuery(_ channel: ChatChannel) -> Bool { - if let filter = filter { - return filter(channel) - } - - if clientConfig.isChannelAutomaticFilteringEnabled { - // When auto-filtering is enabled the channel will appear or not automatically if the - // query matches the DB Predicate. So here we default to saying it always belong to the current query. - return true + private func linkingAction(for channel: ChatChannel, channelCustom: ChannelCustom?) -> LinkingAction { + if let groupKey = query.groupKey { + if let updatedGroupKey = channelCustom?.custom?.group { + return groupKey == updatedGroupKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ? .link : .unlink + } + return .none + } else { + if let filter = filter { + return filter(channel) ? .link : .unlink + } + + if clientConfig.isChannelAutomaticFilteringEnabled { + // When auto-filtering is enabled the channel will appear or not automatically if the + // query matches the DB Predicate. So here we default to saying it always belong to the current query. + return .link + } + + return .none } + } +} - return false +extension ChannelListLinker { + enum LinkingAction { + case link, unlink, none } } diff --git a/Sources/StreamChat/Workers/ChannelListUpdater.swift b/Sources/StreamChat/Workers/ChannelListUpdater.swift index dac38c36755..f1fd9d2c399 100644 --- a/Sources/StreamChat/Workers/ChannelListUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelListUpdater.swift @@ -40,62 +40,6 @@ class ChannelListUpdater: Worker, @unchecked Sendable { } } - func prefill( - group: GroupedChannelsGroup, - for query: ChannelListQuery, - filter: (@Sendable (ChatChannel) -> Bool)? = nil, - completion: (@Sendable (Result<[ChatChannel], Error>) -> Void)? = nil - ) { - let channels = filter.map { group.channels.filter($0) } ?? group.channels - nonisolated(unsafe) var savedChannels: [ChatChannel] = [] - database.write { session in - let queryDTO = session.saveQuery(query: query) - queryDTO.channels.removeAll() - - savedChannels = 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 appendToQuery( - group: GroupedChannelsGroup, - for query: ChannelListQuery, - filter: (@Sendable (ChatChannel) -> Bool)? = nil, - completion: (@Sendable (Result<[ChatChannel], Error>) -> Void)? = nil - ) { - let channels = filter.map { group.channels.filter($0) } ?? group.channels - nonisolated(unsafe) var savedChannels: [ChatChannel] = [] - database.write { session in - let queryDTO = session.saveQuery(query: query) - savedChannels = channels.compactMapLoggingError { channel in - guard let channelDTO = session.channel(cid: channel.cid) else { - log.warning("Append 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 @Sendable (Result, Error>) -> Void) { guard channelCount > 0 else { completion(.success(Set())) @@ -222,77 +166,98 @@ class ChannelListUpdater: Worker, @unchecked Sendable { } } + func paginationCursor(for groupKey: String, completion: @escaping @Sendable (Result) -> Void) { + database.read { session in + session.channelListQuery(ChannelListQuery(groupKey: groupKey))?.next + } completion: { result in + completion(result) + } + } + + func paginationCursor(for groupKey: String) async throws -> String? { + try await withCheckedThrowingContinuation { continuation in + paginationCursor(for: groupKey, completion: { continuation.resume(with: $0) }) + } + } + /// Queries grouped channel groups for the app. /// - /// When `pagination` is non-nil, only that single group is paginated using its cursor and the + /// When `groupPagination` is non-nil, only that single group is paginated using its cursor and the /// response payload contains only that group. Unread-channel counts are not persisted in the /// paginated case since they reflect just the requested group. func queryGroupedChannels( - limit: Int? = nil, - pagination: GroupChannelsPagination? = nil, - watch: Bool = false, - presence: Bool = false, - groupHandler: @escaping @Sendable (String, ChatChannel) -> String, - completion: @escaping @MainActor (Result) -> Void + groupPagination: GroupedChannelsPagination?, + limit: Int?, + watch: Bool, + presence: Bool, + completion: @escaping @Sendable (Result) -> Void ) { - let request: GroupedQueryChannelsRequestBody - if let pagination { - let group = GroupedQueryChannelsRequestGroup( - limit: limit, - next: pagination.next, - prev: pagination.prev - ) - request = GroupedQueryChannelsRequestBody( - limit: nil, - groups: [pagination.groupKey: group], - watch: watch, - presence: presence - ) - } else { - request = GroupedQueryChannelsRequestBody( - limit: limit, - groups: nil, - watch: watch, - presence: presence - ) - } + // Only one group is supported for pagination, nil means all groups are returned with the first page + let paginatedGroup: [String: GroupedQueryChannelsRequestGroup]? = { + guard let groupPagination else { return nil } + return [groupPagination.groupKey: GroupedQueryChannelsRequestGroup(limit: limit, next: groupPagination.next, prev: nil)] + }() + let request = GroupedQueryChannelsRequestBody( + limit: paginatedGroup == nil ? limit : nil, + groups: paginatedGroup, + watch: watch, + presence: presence + ) + let isInitialFetch = request.groups == nil + let isFirstPageForSingleGroup = groupPagination?.next == nil let endpoint: Endpoint = .groupedChannels(request: request) - let isPaginating = pagination != nil - apiClient.request(endpoint: endpoint) { [database] result in switch result { + case let .failure(error): + completion(.failure(error)) case let .success(payload): database.write(converting: { session in - if !isPaginating { + if isInitialFetch { 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 + for (groupKey, groupPayload) in payload.groups { + let queryDTO = session.saveQuery(query: ChannelListQuery(groupKey: groupKey)) + if isInitialFetch || isFirstPageForSingleGroup { + queryDTO.channels.removeAll() + } + queryDTO.next = groupPayload.next + let channels = groupPayload.channels.compactMapLoggingError { channelPayload in let dto = try session.saveChannel(payload: channelPayload) + queryDTO.channels.insert(dto) return try dto.asModel() } - groups[name] = GroupedChannelsGroup( - groupKey: name, + groups[groupKey] = GroupedChannelsGroup( + groupKey: groupKey, channels: channels, unreadChannels: groupPayload.unreadChannels, next: groupPayload.next, - prev: groupPayload.prev, - groupHandler: groupHandler + prev: groupPayload.prev ) } - return GroupedChannels(groups: groups, groupHandler: groupHandler) + return GroupedChannels(groups: groups) }, completion: { result in - DispatchQueue.main.async { - completion(result) - } + completion(result) }) - case let .failure(error): - DispatchQueue.main.async { - completion(.failure(error)) - } + } + } + } + + func queryGroupedChannels( + groupPagination: GroupedChannelsPagination?, + limit: Int?, + watch: Bool, + presence: Bool + ) async throws -> GroupedChannels { + try await withCheckedThrowingContinuation { continuation in + queryGroupedChannels( + groupPagination: groupPagination, + limit: limit, + watch: watch, + presence: presence, + ) { result in + continuation.resume(with: result) } } } @@ -337,30 +302,6 @@ private extension ChannelListUpdater { } extension ChannelListUpdater { - @discardableResult func prefill( - group: GroupedChannelsGroup, - for query: ChannelListQuery, - filter: (@Sendable (ChatChannel) -> Bool)? = nil - ) async throws -> [ChatChannel] { - try await withCheckedThrowingContinuation { continuation in - prefill(group: group, for: query, filter: filter) { result in - continuation.resume(with: result) - } - } - } - - @discardableResult func appendToQuery( - group: GroupedChannelsGroup, - for query: ChannelListQuery, - filter: (@Sendable (ChatChannel) -> Bool)? = nil - ) async throws -> [ChatChannel] { - try await withCheckedThrowingContinuation { continuation in - appendToQuery(group: group, for: query, filter: filter) { result in - continuation.resume(with: result) - } - } - } - @discardableResult func update(channelListQuery: ChannelListQuery) async throws -> [ChatChannel] { try await withCheckedThrowingContinuation { continuation in update(channelListQuery: channelListQuery) { result in @@ -369,26 +310,6 @@ extension ChannelListUpdater { } } - func queryGroupedChannels( - limit: Int? = nil, - pagination: GroupChannelsPagination? = nil, - watch: Bool = false, - presence: Bool = false, - groupHandler: @escaping @Sendable (String, ChatChannel) -> String - ) async throws -> GroupedChannels { - try await withCheckedThrowingContinuation { continuation in - queryGroupedChannels( - limit: limit, - pagination: pagination, - watch: watch, - presence: presence, - groupHandler: groupHandler - ) { 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 797c90eab38..a13bb052a2d 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift @@ -37,15 +37,6 @@ class ChatChannelListController_Mock: ChatChannelListController, Spy, @unchecked refreshLoadedChannelsResult.map(completion) } - @Atomic var prefill_groups: [GroupedChannelsGroup] = [] - override func prefill( - group: GroupedChannelsGroup, - completion: (@Sendable (Error?) -> Void)? = nil - ) { - record() - _prefill_groups.mutate { $0.append(group) } - completion?(nil) - } } extension ChatChannelListController_Mock { diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/State/ChannelList_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/State/ChannelList_Mock.swift index 1e63701d94a..60865d04b7e 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/State/ChannelList_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/State/ChannelList_Mock.swift @@ -19,14 +19,12 @@ public class ChannelList_Mock: ChannelList, @unchecked Sendable { override init( query: ChannelListQuery, dynamicFilter: (@Sendable (ChatChannel) -> Bool)? = nil, - groupHandler: (@Sendable (String, ChatChannel) -> String)? = nil, client: ChatClient, environment: ChannelList.Environment = .init() ) { super.init( query: query, dynamicFilter: dynamicFilter, - groupHandler: groupHandler, client: client, environment: environment ) @@ -36,11 +34,6 @@ public class ChannelList_Mock: ChannelList, @unchecked Sendable { state.channels = channels } - @Atomic public var prefillGroups: [GroupedChannelsGroup] = [] - override public func prefill(group: GroupedChannelsGroup) async throws { - _prefillGroups.mutate { $0.append(group) } - } - @Atomic public var refreshLoadedChannelsCallCount = 0 @Atomic public var refreshLoadedChannelsResult: Result, Error> = .success([]) override public func refreshLoadedChannels() async throws -> Set { @@ -48,10 +41,8 @@ public class ChannelList_Mock: ChannelList, @unchecked Sendable { return try refreshLoadedChannelsResult.get() } - public var loadNextChannelsIsCalled = false override public func loadMoreChannels(limit: Int? = nil) async throws -> [ChatChannel] { - loadNextChannelsIsCalled = true - return await MainActor.run { + await MainActor.run { Array(state.channels) } } diff --git a/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift b/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift index cf4808c4c95..abc38e785f1 100644 --- a/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift +++ b/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift @@ -13,24 +13,15 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable @Atomic var update_completion: (@Sendable (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: (@Sendable (Result) -> Void)? @Atomic var refreshLoadedChannelsResult: Result, Error>? - @Atomic var refreshLoadedChannels_channelCounts: [Int] = [] @Atomic var queryGroupedChannels_callCount = 0 - @Atomic var queryGroupedChannels_limits: [Int?] = [] - @Atomic var queryGroupedChannels_paginations: [GroupChannelsPagination?] = [] + @Atomic var queryGroupedChannels_paginations: [GroupedChannelsPagination?] = [] @Atomic var queryGroupedChannels_result: Result? - @Atomic var appendToQuery_queries: [ChannelListQuery] = [] - @Atomic var appendToQuery_channels: [[ChatChannel]] = [] - @Atomic var appendToQuery_result: Result<[ChatChannel], Error>? - @Atomic var markAllRead_completion: (@Sendable (Error?) -> Void)? var startWatchingChannels_callCount = 0 @@ -48,20 +39,12 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable 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_limits.removeAll() queryGroupedChannels_paginations.removeAll() queryGroupedChannels_result = nil - appendToQuery_queries.removeAll() - appendToQuery_channels.removeAll() - appendToQuery_result = nil markAllRead_completion = nil startWatchingChannels_cids.removeAll() @@ -78,17 +61,6 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable update_completion_result?.invoke(with: completion) } - override func prefill( - group: GroupedChannelsGroup, - for query: ChannelListQuery, - filter: (@Sendable (ChatChannel) -> Bool)? = nil, - completion: (@Sendable (Result<[ChatChannel], Error>) -> Void)? = nil - ) { - _prefill_queries.mutate { $0.append(query) } - _prefill_channels.mutate { $0.append(group.channels) } - super.prefill(group: group, for: query, filter: filter, completion: completion) - } - override func markAllRead(completion: (@Sendable (Error?) -> Void)? = nil) { markAllRead_completion = completion } @@ -107,52 +79,47 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable completion: @escaping @Sendable (Result, any Error>) -> Void ) { record() - _refreshLoadedChannels_channelCounts.mutate { $0.append(channelCount) } refreshLoadedChannelsResult?.invoke(with: completion) } + override func paginationCursor( + for groupKey: String, + completion: @escaping @Sendable (Result) -> Void + ) { + do { + let cursor = try database.readAndWait { session in + session.channelListQuery(ChannelListQuery(groupKey: groupKey))?.next + } + completion(.success(cursor)) + } catch { + completion(.failure(error)) + } + } + override func queryGroupedChannels( - limit: Int? = nil, - pagination: GroupChannelsPagination? = nil, - watch: Bool = false, - presence: Bool = false, - groupHandler: @escaping @Sendable (String, ChatChannel) -> String = { key, _ in key }, - completion: @escaping @MainActor (Result) -> Void + groupPagination: GroupedChannelsPagination?, + limit: Int?, + watch: Bool, + presence: Bool, + completion: @escaping @Sendable (Result) -> Void ) { _queryGroupedChannels_callCount.mutate { $0 += 1 } - _queryGroupedChannels_limits.mutate { $0.append(limit) } - _queryGroupedChannels_paginations.mutate { $0.append(pagination) } + _queryGroupedChannels_paginations.mutate { $0.append(groupPagination) } if let result = queryGroupedChannels_result { DispatchQueue.main.async { completion(result) } } else { super.queryGroupedChannels( + groupPagination: groupPagination, limit: limit, - pagination: pagination, watch: watch, presence: presence, - groupHandler: groupHandler, completion: completion ) } } - override func appendToQuery( - group: GroupedChannelsGroup, - for query: ChannelListQuery, - filter: (@Sendable (ChatChannel) -> Bool)? = nil, - completion: (@Sendable (Result<[ChatChannel], Error>) -> Void)? = nil - ) { - _appendToQuery_queries.mutate { $0.append(query) } - _appendToQuery_channels.mutate { $0.append(group.channels) } - if let result = appendToQuery_result { - completion?(result) - } else { - super.appendToQuery(group: group, for: query, filter: filter, completion: completion) - } - } - override func link( channel: ChatChannel, with query: ChannelListQuery, diff --git a/Tests/StreamChatTests/ChatClient_Tests.swift b/Tests/StreamChatTests/ChatClient_Tests.swift index 5fb69f3a5ed..6bad3d1286b 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -293,7 +293,7 @@ final class ChatClient_Tests: XCTestCase { var receivedGroupedChannels: GroupedChannels? var receivedError: Error? - client.queryGroupedChannels(limit: 4, presence: false, watch: true, groupHandler: { key, _ in key }) { result in + client.queryGroupedChannels(limit: 4, presence: false, watch: true) { result in switch result { case let .success(groupedChannels): receivedGroupedChannels = groupedChannels @@ -329,8 +329,7 @@ final class ChatClient_Tests: XCTestCase { let group = GroupedChannelsGroup( groupKey: "all", channels: [firstChannel, secondChannel, thirdChannel], - unreadChannels: 0, - groupHandler: { key, _ in key } + unreadChannels: 0 ) XCTAssertEqual(group.unreadChannels, 2) diff --git a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift index 65bd2d39399..53888965d6d 100644 --- a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift @@ -75,6 +75,62 @@ final class ChannelListController_Tests: XCTestCase { XCTAssertEqual(controller.state, .localDataFetched) } + func test_synchronize_whenQueryHasGroupKey_doesNotCallQueryGroupedChannels() { + let groupedQuery = ChannelListQuery(groupKey: "all") + let groupedController = ChatChannelListController( + query: groupedQuery, + client: client, + filter: { _ in true }, + environment: env.environment + ) + + groupedController.synchronize() + groupedController.synchronize() + + XCTAssertEqual(env.channelListUpdater?.queryGroupedChannels_callCount, 0) + XCTAssertTrue(env.channelListUpdater?.update_queries.isEmpty ?? false) + XCTAssertEqual(groupedController.state, .remoteDataFetched) + } + + func test_loadNextChannels_whenQueryHasGroupKey_readsCursorFromQueryDTO() throws { + let groupedQuery = ChannelListQuery(groupKey: "all") + let groupedController = ChatChannelListController( + query: groupedQuery, + client: client, + filter: { _ in true }, + environment: env.environment + ) + try client.databaseContainer.writeSynchronously { session in + let queryDTO = session.saveQuery(query: groupedQuery) + queryDTO.next = "cursor-1" + } + + groupedController.loadNextChannels() + + XCTAssertEqual(env.channelListUpdater?.queryGroupedChannels_callCount, 1) + let pagination = env.channelListUpdater?.queryGroupedChannels_paginations.first ?? nil + XCTAssertEqual("all", pagination?.groupKey) + XCTAssertEqual("cursor-1", pagination?.next) + } + + func test_loadNextChannels_whenQueryDTOHasNoNextCursor_marksAsFullyLoaded() throws { + let groupedQuery = ChannelListQuery(groupKey: "all") + let groupedController = ChatChannelListController( + query: groupedQuery, + client: client, + filter: { _ in true }, + environment: env.environment + ) + try client.databaseContainer.writeSynchronously { session in + _ = session.saveQuery(query: groupedQuery) + } + + groupedController.loadNextChannels() + + XCTAssertEqual(env.channelListUpdater?.queryGroupedChannels_callCount ?? 0, 0) + XCTAssertTrue(groupedController.hasLoadedAllPreviousChannels) + } + func test_synchronize_changesControllerStateOnError() { // Check if controller is inactive initially. assert(controller.state == .initialized) @@ -224,264 +280,6 @@ 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, groupHandler: { key, _ in key })) { 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, groupHandler: { key, _ in key })) { 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, groupHandler: { key, _ in key })) { 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, groupHandler: { key, _ in key })) { 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, groupHandler: { key, _ in key })) { 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, groupHandler: { key, _ in key })) { error in - XCTAssertNil(error) - prefillExpectation.fulfill() - } - waitForExpectations(timeout: defaultTimeout) - - controller.synchronize() - - AssertAsync.willBeEqual(controller.channels.count, prefilledChannels.count) - } - - func test_prefill_whenChannelsAccessedBeforePrefillAndPrefilledCountIsBelowPageSize_observerReflectsPrefilledChannels() { - query = .init(filter: .in(.members, values: [memberId]), pageSize: 10) - controller = ChatChannelListController(query: query, client: client, environment: env.environment) - XCTAssertEqual(controller.channels.count, 0) - - 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, groupHandler: { key, _ in key })) { error in - XCTAssertNil(error) - prefillExpectation.fulfill() - } - waitForExpectations(timeout: defaultTimeout) - - controller.synchronize() - - XCTAssertTrue(env.channelListUpdater?.update_queries.isEmpty ?? false) - AssertAsync.willBeEqual(Set(controller.channels.map(\.cid)), Set(prefilledChannels.map(\.cid))) - } - - func test_prefill_whenChannelsAccessedBeforePrefillAndPrefilledCountExceedsPageSize_observerReflectsAllPrefilledChannels() { - query = .init(filter: .in(.members, values: [memberId]), pageSize: 2) - controller = ChatChannelListController(query: query, client: client, environment: env.environment) - XCTAssertEqual(controller.channels.count, 0) - - 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, groupHandler: { key, _ in key })) { error in - XCTAssertNil(error) - prefillExpectation.fulfill() - } - waitForExpectations(timeout: defaultTimeout) - - controller.synchronize() - - XCTAssertTrue(env.channelListUpdater?.update_queries.isEmpty ?? false) - AssertAsync.willBeEqual(Set(controller.channels.map(\.cid)), Set(prefilledChannels.map(\.cid))) - } - - 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, groupHandler: { key, _ in key })) { 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 { @@ -839,7 +637,7 @@ final class ChannelListController_Tests: XCTestCase { AssertAsync.willBeEqual(env.channelListUpdater?.unlink_callCount, 1) } - func test_didReceiveEvent_whenMessageNewEvent_whenFilterDoesNotMatch_shouldUnlinkChannelFromQuery() throws { + func test_didReceiveEvent_whenMessageNewEvent_whenFilterDoesNotMatch_shouldNotUnlinkChannelFromQuery() throws { let filter: (ChatChannel) -> Bool = { channel in channel.memberCount == 1 } @@ -864,10 +662,10 @@ final class ChannelListController_Tests: XCTestCase { } wait(for: [eventExpectation], timeout: defaultTimeout) - AssertAsync.willBeEqual(env.channelListUpdater?.unlink_callCount, 1) + XCTAssertEqual(env.channelListUpdater?.unlink_callCount, 0) } - func test_didReceiveEvent_whenNotificationMessageNewEvent_whenFilterDoesNotMatch_shouldUnlinkChannelFromQuery() throws { + func test_didReceiveEvent_whenNotificationMessageNewEvent_whenFilterDoesNotMatch_shouldNotUnlinkChannelFromQuery() throws { let filter: (ChatChannel) -> Bool = { channel in channel.memberCount == 1 } @@ -892,7 +690,7 @@ final class ChannelListController_Tests: XCTestCase { } wait(for: [eventExpectation], timeout: defaultTimeout) - AssertAsync.willBeEqual(env.channelListUpdater?.unlink_callCount, 1) + XCTAssertEqual(env.channelListUpdater?.unlink_callCount, 0) } func test_didReceiveEvent_whenChannelUpdatedEvent__whenFilterMatches_shouldNotUnlinkChannelFromQuery() throws { @@ -2313,24 +2111,6 @@ 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 @Sendable (ChatChannel) -> Bool) { // Prepare controller diff --git a/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift b/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift index c9af62887cc..924413db4ff 100644 --- a/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift +++ b/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift @@ -227,7 +227,7 @@ class SyncRepository_Tests: XCTestCase { XCTAssertCall("runQueuedRequests(completion:)", on: offlineRequestsRepository, times: 1) } - func test_syncLocalState_prefilledController_callsQueryGroupedChannelsAndSkipsRefresh() throws { + func test_syncLocalState_groupedController_callsQueryGroupedChannelsAndSkipsRefresh() throws { let cid = ChannelId.unique try prepareForSyncLocalStorage( createUser: true, @@ -236,41 +236,39 @@ class SyncRepository_Tests: XCTestCase { cid: cid ) - var prefilledQuery = ChannelListQuery(filter: .exists(.cid)) - prefilledQuery.groupKey = "all" - let chatListController = ChatChannelListController_Mock(query: prefilledQuery, client: client) + var groupedQuery = ChannelListQuery(filter: .exists(.cid)) + groupedQuery.groupKey = "all" + let chatListController = ChatChannelListController_Mock(query: groupedQuery, 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, groupHandler: { key, _ in key }) - channelListUpdater.queryGroupedChannels_result = .success(.init(groups: ["all": refreshedGroup], groupHandler: { key, _ in key })) + 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 groupedCid = ChannelId.unique let standardCid = ChannelId.unique try prepareForSyncLocalStorage( createUser: true, lastSynchedEventDate: Date().addingTimeInterval(-3600), createChannel: true, - cid: prefilledCid + cid: groupedCid ) - 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) + var groupedQuery = ChannelListQuery(filter: .exists(.cid)) + groupedQuery.groupKey = "current" + let groupedController = ChatChannelListController_Mock(query: groupedQuery, client: client) + groupedController.state_mock = .remoteDataFetched + groupedController.channels_mock = [.mock(cid: groupedCid)] + repository.startTrackingChannelListController(groupedController) let standardController = ChatChannelListController_Mock(query: .init(filter: .in(.cid, values: [standardCid])), client: client) standardController.state_mock = .remoteDataFetched @@ -278,19 +276,17 @@ class SyncRepository_Tests: XCTestCase { standardController.refreshLoadedChannelsResult = .success(Set([standardCid])) repository.startTrackingChannelListController(standardController) - let refreshedGroup = GroupedChannelsGroup(groupKey: "current", channels: [.mock(cid: prefilledCid)], unreadChannels: 0, groupHandler: { key, _ in key }) - channelListUpdater.queryGroupedChannels_result = .success(.init(groups: ["current": refreshedGroup], groupHandler: { key, _ in key })) + let refreshedGroup = GroupedChannelsGroup(groupKey: "current", channels: [.mock(cid: groupedCid)], unreadChannels: 0) + channelListUpdater.queryGroupedChannels_result = .success(.init(groups: ["current": refreshedGroup])) waitForSyncLocalStateRun() XCTAssertEqual(channelListUpdater.queryGroupedChannels_callCount, 1) - XCTAssertNotCall("refreshLoadedChannels(completion:)", on: prefilledController) + XCTAssertNotCall("refreshLoadedChannels(completion:)", on: groupedController) 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 { + func test_syncLocalState_noGroupedControllers_doesNotCallQueryGroupedChannels() throws { let cid = ChannelId.unique try prepareForSyncLocalStorage( createUser: true, @@ -311,7 +307,7 @@ class SyncRepository_Tests: XCTestCase { XCTAssertCall("refreshLoadedChannels(completion:)", on: chatListController, times: 1) } - func test_syncLocalState_prefilledChannelList_callsQueryGroupedChannelsAndSkipsRefresh() throws { + func test_syncLocalState_groupedChannelList_callsQueryGroupedChannelsAndSkipsRefresh() throws { let cid = ChannelId.unique try prepareForSyncLocalStorage( createUser: true, @@ -320,49 +316,46 @@ class SyncRepository_Tests: XCTestCase { cid: cid ) - var prefilledQuery = ChannelListQuery(filter: .exists(.cid)) - prefilledQuery.groupKey = "all" - let channelList = ChannelList_Mock.mock(query: prefilledQuery, client: client) + var groupedQuery = ChannelListQuery(filter: .exists(.cid)) + groupedQuery.groupKey = "all" + let channelList = ChannelList_Mock.mock(query: groupedQuery, client: client) repository.startTrackingChannelList(channelList) - let refreshedGroup = GroupedChannelsGroup(groupKey: "all", channels: [.mock(cid: cid)], unreadChannels: 0, groupHandler: { key, _ in key }) - channelListUpdater.queryGroupedChannels_result = .success(.init(groups: ["all": refreshedGroup], groupHandler: { key, _ in key })) + 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) XCTAssertEqual(channelList.refreshLoadedChannelsCallCount, 0) - XCTAssertEqual(channelList.prefillGroups.map(\.groupKey), ["all"]) } func test_syncLocalState_mixedChannelLists_callsGroupedOnceAndRefreshesOnlyStandard() throws { - let prefilledCid = ChannelId.unique + let groupedCid = ChannelId.unique let standardCid = ChannelId.unique try prepareForSyncLocalStorage( createUser: true, lastSynchedEventDate: Date().addingTimeInterval(-3600), createChannel: true, - cid: prefilledCid + cid: groupedCid ) - var prefilledQuery = ChannelListQuery(filter: .exists(.cid)) - prefilledQuery.groupKey = "current" - let prefilledChannelList = ChannelList_Mock.mock(query: prefilledQuery, client: client) - repository.startTrackingChannelList(prefilledChannelList) + var groupedQuery = ChannelListQuery(filter: .exists(.cid)) + groupedQuery.groupKey = "current" + let groupedChannelList = ChannelList_Mock.mock(query: groupedQuery, client: client) + repository.startTrackingChannelList(groupedChannelList) let standardChannelList = ChannelList_Mock.mock(query: .init(filter: .in(.cid, values: [standardCid])), client: client) standardChannelList.refreshLoadedChannelsResult = .success(Set([standardCid])) repository.startTrackingChannelList(standardChannelList) - let refreshedGroup = GroupedChannelsGroup(groupKey: "current", channels: [.mock(cid: prefilledCid)], unreadChannels: 0, groupHandler: { key, _ in key }) - channelListUpdater.queryGroupedChannels_result = .success(.init(groups: ["current": refreshedGroup], groupHandler: { key, _ in key })) + let refreshedGroup = GroupedChannelsGroup(groupKey: "current", channels: [.mock(cid: groupedCid)], unreadChannels: 0) + channelListUpdater.queryGroupedChannels_result = .success(.init(groups: ["current": refreshedGroup])) waitForSyncLocalStateRun() XCTAssertEqual(channelListUpdater.queryGroupedChannels_callCount, 1) - XCTAssertEqual(prefilledChannelList.refreshLoadedChannelsCallCount, 0) + XCTAssertEqual(groupedChannelList.refreshLoadedChannelsCallCount, 0) XCTAssertEqual(standardChannelList.refreshLoadedChannelsCallCount, 1) - XCTAssertEqual(prefilledChannelList.prefillGroups.map(\.groupKey), ["current"]) - XCTAssertTrue(standardChannelList.prefillGroups.isEmpty) } func test_syncLocalState_ignoresTheCooldown() throws { diff --git a/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift b/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift index dd820aa7e3c..fe1a66ace8f 100644 --- a/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift +++ b/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift @@ -89,117 +89,73 @@ final class ChannelList_Tests: XCTestCase { await XCTAssertEqual(nextChannelListPayload.channels.map(\.channel.cid.rawValue), channelList.state.channels.map(\.cid.rawValue)) } - // MARK: - Prefill - - func test_prefill_skipsInitialGetRequest() async throws { - await setUpChannelList(usesMockedChannelUpdater: false, loadState: false, pageSize: 2) - let prefilledChannels = try await makePrefilledChannels(count: 2, createdAtOffset: 0) - - try await channelList.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0, groupHandler: { key, _ in key })) - try await channelList.get() - - XCTAssertTrue(env.client.mockAPIClient.request_allRecordedCalls.isEmpty) - await XCTAssertEqual("all", channelList.state.query.groupKey) - await XCTAssertEqual(prefilledChannels.map(\.cid.rawValue), channelList.state.channels.map(\.cid.rawValue)) - } - - func test_prefill_whenNextCursorIsNil_loadMoreChannelsReturnsEmpty() async throws { - await setUpChannelList( - usesMockedChannelUpdater: true, - loadState: false, - pageSize: 2, - groupHandler: { key, _ in key } + func test_get_whenQueryHasGroupKey_doesNotCallQueryGroupedChannels() async throws { + let groupedQuery = ChannelListQuery(groupKey: "all") + let environment = env.channelListEnvironment(usesMockedUpdater: true) + channelList = await ChannelList( + query: groupedQuery, + dynamicFilter: nil, + client: env.client, + environment: environment ) - let prefilledChannels = try await makePrefilledChannels(count: 3, createdAtOffset: 0) + _ = await channelList.state - try await channelList.prefill(group: GroupedChannelsGroup( - groupKey: "all", - channels: prefilledChannels, - unreadChannels: 0, - groupHandler: { key, _ in key } - )) - let loadedChannels = try await channelList.loadMoreChannels(limit: 2) + try await channelList.get() + try await channelList.get() - XCTAssertEqual([], loadedChannels) XCTAssertEqual(0, env.channelListUpdaterMock.queryGroupedChannels_callCount) XCTAssertTrue(env.channelListUpdaterMock.update_queries.isEmpty) } - func test_prefill_loadMoreChannels_usesGroupedPaginationWithStoredCursor() async throws { - await setUpChannelList( - usesMockedChannelUpdater: true, - loadState: false, - pageSize: 2, - groupHandler: { key, _ in key } - ) - let prefilledChannels = try await makePrefilledChannels(count: 3, createdAtOffset: 0) - let nextPageChannels = try await makePrefilledChannels(count: 2, createdAtOffset: 3) - let nextPageGroup = GroupedChannelsGroup( - groupKey: "all", - channels: nextPageChannels, - unreadChannels: 0, - next: "cursor-2", - groupHandler: { key, _ in key } + func test_loadMoreChannels_whenQueryHasGroupKey_readsCursorFromQueryDTO() async throws { + let groupedQuery = ChannelListQuery(groupKey: "all") + let environment = env.channelListEnvironment(usesMockedUpdater: true) + channelList = await ChannelList( + query: groupedQuery, + dynamicFilter: nil, + client: env.client, + environment: environment ) + _ = await channelList.state + try await env.client.mockDatabaseContainer.write { session in + let queryDTO = session.saveQuery(query: groupedQuery) + queryDTO.next = "cursor-1" + } env.channelListUpdaterMock.queryGroupedChannels_result = .success( - GroupedChannels(groups: ["all": nextPageGroup], groupHandler: { key, _ in key }) + GroupedChannels(groups: ["all": GroupedChannelsGroup( + groupKey: "all", + channels: [], + unreadChannels: 0, + next: "cursor-2" + )]) ) - env.channelListUpdaterMock.appendToQuery_result = .success(nextPageChannels) - try await channelList.prefill(group: GroupedChannelsGroup( - groupKey: "all", - channels: prefilledChannels, - unreadChannels: 0, - next: "cursor-1", - groupHandler: { key, _ in key } - )) - let returned = try await channelList.loadMoreChannels(limit: 2) + _ = try await channelList.loadMoreChannels(limit: 5) XCTAssertEqual(1, env.channelListUpdaterMock.queryGroupedChannels_callCount) - XCTAssertEqual(2, env.channelListUpdaterMock.queryGroupedChannels_limits.first ?? nil) let pagination = env.channelListUpdaterMock.queryGroupedChannels_paginations.first ?? nil XCTAssertEqual("all", pagination?.groupKey) XCTAssertEqual("cursor-1", pagination?.next) - XCTAssertNil(pagination?.prev) - XCTAssertEqual(nextPageChannels.map(\.cid.rawValue), returned.map(\.cid.rawValue)) - let cursorAfter = await channelList.state.groupPaginationCursor - XCTAssertEqual("cursor-2", cursorAfter) - let fullyLoaded = await channelList.state.hasLoadedAllPreviousChannels - XCTAssertFalse(fullyLoaded) } - func test_prefill_loadMoreChannels_whenResponseHasNoNextCursor_marksAsFullyLoaded() async throws { - await setUpChannelList( - usesMockedChannelUpdater: true, - loadState: false, - pageSize: 2, - groupHandler: { key, _ in key } - ) - let prefilledChannels = try await makePrefilledChannels(count: 3, createdAtOffset: 0) - let nextPageChannels = try await makePrefilledChannels(count: 2, createdAtOffset: 3) - let nextPageGroup = GroupedChannelsGroup( - groupKey: "all", - channels: nextPageChannels, - unreadChannels: 0, - next: nil, - groupHandler: { key, _ in key } - ) - env.channelListUpdaterMock.queryGroupedChannels_result = .success( - GroupedChannels(groups: ["all": nextPageGroup], groupHandler: { key, _ in key }) + func test_loadMoreChannels_whenQueryDTOHasNoNextCursor_marksAsFullyLoaded() async throws { + let groupedQuery = ChannelListQuery(groupKey: "all") + let environment = env.channelListEnvironment(usesMockedUpdater: true) + channelList = await ChannelList( + query: groupedQuery, + dynamicFilter: nil, + client: env.client, + environment: environment ) - env.channelListUpdaterMock.appendToQuery_result = .success(nextPageChannels) + _ = await channelList.state + try await env.client.mockDatabaseContainer.write { session in + _ = session.saveQuery(query: groupedQuery) + } - try await channelList.prefill(group: GroupedChannelsGroup( - groupKey: "all", - channels: prefilledChannels, - unreadChannels: 0, - next: "cursor-1", - groupHandler: { key, _ in key } - )) - _ = try await channelList.loadMoreChannels(limit: 2) + let returned = try await channelList.loadMoreChannels(limit: 5) - let cursorAfter = await channelList.state.groupPaginationCursor - XCTAssertNil(cursorAfter) + XCTAssertEqual([], returned) + XCTAssertEqual(0, env.channelListUpdaterMock.queryGroupedChannels_callCount) let fullyLoaded = await channelList.state.hasLoadedAllPreviousChannels XCTAssertTrue(fullyLoaded) } @@ -215,82 +171,6 @@ final class ChannelList_Tests: XCTestCase { XCTAssertEqual(1, env.channelListUpdaterMock.update_queries.count) } - func test_prefill_whenNoChannelsWereSaved_loadMoreChannelsDoesNotRequestNextPage() async throws { - await setUpChannelList(usesMockedChannelUpdater: true, loadState: false, pageSize: 2) - - try await channelList.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: [], unreadChannels: 0, groupHandler: { key, _ in key })) - let loadedChannels = try await channelList.loadMoreChannels(limit: 2) - - XCTAssertEqual([], loadedChannels) - XCTAssertTrue(env.channelListUpdaterMock.update_queries.isEmpty) - } - - func test_prefill_whenPrefilledCountExceedsPageSize_stateExposesAllPrefilledChannels() async throws { - await setUpChannelList(usesMockedChannelUpdater: false, loadState: false, pageSize: 2) - let prefilledChannels = try await makePrefilledChannels(count: 3, createdAtOffset: 0) - - try await channelList.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0, groupHandler: { key, _ in key })) - - await XCTAssertEqual(prefilledChannels.map(\.cid.rawValue), channelList.state.channels.map(\.cid.rawValue)) - } - - func test_prefill_whenStateAccessedBeforePrefillAndPrefilledCountIsBelowPageSize_stateExposesPrefilledChannels() async throws { - await setUpChannelList(usesMockedChannelUpdater: false, pageSize: 10) - await XCTAssertEqual(0, channelList.state.channels.count) - let prefilledChannels = try await makePrefilledChannels(count: 3, createdAtOffset: 0) - - try await channelList.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0, groupHandler: { key, _ in key })) - try await channelList.get() - - XCTAssertTrue(env.client.mockAPIClient.request_allRecordedCalls.isEmpty) - await XCTAssertEqual(prefilledChannels.map(\.cid.rawValue), channelList.state.channels.map(\.cid.rawValue)) - } - - func test_prefill_whenStateAccessedBeforePrefillAndPrefilledCountExceedsPageSize_stateExposesAllPrefilledChannels() async throws { - await setUpChannelList(usesMockedChannelUpdater: false, pageSize: 2) - await XCTAssertEqual(0, channelList.state.channels.count) - let prefilledChannels = try await makePrefilledChannels(count: 3, createdAtOffset: 0) - - try await channelList.prefill(group: GroupedChannelsGroup(groupKey: "all", channels: prefilledChannels, unreadChannels: 0, groupHandler: { key, _ in key })) - try await channelList.get() - - XCTAssertTrue(env.client.mockAPIClient.request_allRecordedCalls.isEmpty) - await XCTAssertEqual(prefilledChannels.map(\.cid.rawValue), channelList.state.channels.map(\.cid.rawValue)) - } - - func test_prefill_replacesOnlyCurrentQueryLinks() async throws { - await setUpChannelList(usesMockedChannelUpdater: false) - let sharedPayload = makeMatchingChannelPayload(createdAtOffset: 0) - let currentOnlyPayload = makeMatchingChannelPayload(createdAtOffset: 1) - let replacementPayload = makeMatchingChannelPayload(createdAtOffset: 2) - let otherQuery = ChannelListQuery(filter: .equal(.cid, to: sharedPayload.channel.cid)) - var prefilledQuery = await channelList.state.query - prefilledQuery.groupKey = "all" - try await env.client.mockDatabaseContainer.write { session in - _ = session.saveChannelList(payload: .init(channels: [sharedPayload, currentOnlyPayload]), query: prefilledQuery) - _ = session.saveChannelList(payload: .init(channels: [sharedPayload]), query: otherQuery) - try session.saveChannel(payload: replacementPayload) - } - let replacementChannel = try env.client.databaseContainer.readSynchronously { session in - try XCTUnwrap(session.channel(cid: replacementPayload.channel.cid)).asModel() - } - let prefilledGroup = GroupedChannelsGroup( - groupKey: "all", - channels: [replacementChannel], - unreadChannels: 0, - groupHandler: { key, _ in key } - ) - - try await channelList.prefill(group: prefilledGroup) - - try env.client.databaseContainer.readAndWait { session in - let currentQueryDTO = try XCTUnwrap(session.channelListQuery(prefilledQuery)) - let otherQueryDTO = try XCTUnwrap(session.channelListQuery(otherQuery)) - XCTAssertEqual(Set([replacementPayload.channel.cid.rawValue]), Set(currentQueryDTO.channels.map(\.cid))) - XCTAssertEqual(Set([sharedPayload.channel.cid.rawValue]), Set(otherQueryDTO.channels.map(\.cid))) - } - } - // MARK: - Pagination and Channel Updater Arguments func test_loadChannels_whenChannelUpdaterSucceeds_thenLoadSucceeds() async throws { @@ -731,8 +611,7 @@ final class ChannelList_Tests: XCTestCase { filter: Filter? = nil, pageSize: Int = .channelsPageSize, sort: [Sorting] = [.init(key: .createdAt, isAscending: true)], - dynamicFilter: (@Sendable (ChatChannel) -> Bool)? = nil, - groupHandler: (@Sendable (String, ChatChannel) -> String)? = nil + dynamicFilter: (@Sendable (ChatChannel) -> Bool)? = nil ) { channelList = ChannelList( query: ChannelListQuery( @@ -741,7 +620,6 @@ final class ChannelList_Tests: XCTestCase { pageSize: pageSize ), dynamicFilter: dynamicFilter, - groupHandler: groupHandler, client: env.client, environment: env.channelListEnvironment(usesMockedUpdater: usesMockedChannelUpdater) ) @@ -756,15 +634,6 @@ final class ChannelList_Tests: XCTestCase { .sorted(by: { $0.cid.rawValue < $1.cid.rawValue }) } - private func makePrefilledChannels(count: Int, createdAtOffset: Int) async throws -> [ChatChannel] { - let payload = makeMatchingChannelListPayload(channelCount: count, createdAtOffset: createdAtOffset) - nonisolated(unsafe) var channels: [ChatChannel] = [] - try await env.client.mockDatabaseContainer.write { session in - channels = try payload.channels.map { try session.saveChannel(payload: $0).asModel() } - } - return channels - } - private func makeMatchingChannelPayload(createdAtOffset: Int) -> ChannelPayload { makeMatchingChannelListPayload(channelCount: 1, createdAtOffset: createdAtOffset).channels[0] } diff --git a/Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift b/Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift index 47829667265..5578a0a4dd8 100644 --- a/Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift +++ b/Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift @@ -440,7 +440,7 @@ final class ChannelListUpdater_Tests: XCTestCase { // MARK: - queryGroupedChannels func test_queryGroupedChannels_initial_sendsBodyWithoutGroupsKey() throws { - listUpdater.queryGroupedChannels(limit: 10, groupHandler: { key, _ in key }, completion: { _ in }) + listUpdater.queryGroupedChannels(groupPagination: nil, limit: 10, watch: false, presence: false, completion: { _ in }) let body = try XCTUnwrap(apiClient.request_endpoint?.bodyAsDictionary()) XCTAssertEqual(10, body["limit"] as? Int) @@ -448,11 +448,12 @@ final class ChannelListUpdater_Tests: XCTestCase { } func test_queryGroupedChannels_paginated_sendsBodyWithGroupsKeyAndCursor() throws { - let pagination = GroupChannelsPagination(groupKey: "old", next: "old-cursor", prev: nil) + let pagination = GroupedChannelsPagination(groupKey: "old", next: "old-cursor", prev: nil) listUpdater.queryGroupedChannels( + groupPagination: pagination, limit: 5, - pagination: pagination, - groupHandler: { key, _ in key }, + watch: false, + presence: false, completion: { _ in } ) @@ -471,7 +472,7 @@ final class ChannelListUpdater_Tests: XCTestCase { } nonisolated(unsafe) var completionResult: Result? let exp = expectation(description: "completion called") - listUpdater.queryGroupedChannels(groupHandler: { key, _ in key }) { result in + listUpdater.queryGroupedChannels(groupPagination: nil, limit: nil, watch: false, presence: false) { result in completionResult = result exp.fulfill() } @@ -499,12 +500,9 @@ final class ChannelListUpdater_Tests: XCTestCase { try session.saveCurrentUserGroupedUnreadChannels(["new": 5, "current": 10, "old": 2]) } - let pagination = GroupChannelsPagination(groupKey: "old", next: "cursor", prev: nil) + let pagination = GroupedChannelsPagination(groupKey: "old", next: "cursor", prev: nil) nonisolated(unsafe) var completionCalled = false - listUpdater.queryGroupedChannels( - pagination: pagination, - groupHandler: { key, _ in key } - ) { _ in + listUpdater.queryGroupedChannels(groupPagination: pagination, limit: nil, watch: false, presence: false) { _ in completionCalled = true } @@ -524,33 +522,109 @@ final class ChannelListUpdater_Tests: XCTestCase { XCTAssertEqual(2, counters["old"]) } - func test_appendToQuery_linksNewChannelsWithoutRemovingExisting() throws { - var query = ChannelListQuery(filter: .nonEmpty) - query.groupKey = "all" - let existingCid = ChannelId(type: .messaging, id: .unique) + func test_queryGroupedChannels_initial_linksChannelsToQueryDTOPerGroupKey() throws { + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .user)) + } + let allCid1 = ChannelId(type: .messaging, id: .unique) + let allCid2 = ChannelId(type: .messaging, id: .unique) let newCid = ChannelId(type: .messaging, id: .unique) - nonisolated(unsafe) var newChannel: ChatChannel? + let allChannels = [self.dummyPayload(with: allCid1), self.dummyPayload(with: allCid2)] + let newChannels = [self.dummyPayload(with: newCid)] + + let exp = expectation(description: "completion called") + listUpdater.queryGroupedChannels(groupPagination: nil, limit: nil, watch: false, presence: false) { _ in exp.fulfill() } + let payload = GroupedQueryChannelsPayload( + groups: [ + "all": .init(channels: allChannels, unreadChannels: 0), + "new": .init(channels: newChannels, unreadChannels: 0) + ], + duration: "1ms" + ) + apiClient.test_simulateResponse(.success(payload)) + waitForExpectations(timeout: defaultTimeout) + + let allLinked = try XCTUnwrap(database.viewContext.channelListQuery(ChannelListQuery(groupKey: "all"))) + let newLinked = try XCTUnwrap(database.viewContext.channelListQuery(ChannelListQuery(groupKey: "new"))) + XCTAssertEqual(Set([allCid1.rawValue, allCid2.rawValue]), Set(allLinked.channels.map(\.cid))) + XCTAssertEqual(Set([newCid.rawValue]), Set(newLinked.channels.map(\.cid))) + } + + func test_queryGroupedChannels_initialFetchForSingleGroup_resetsAndLinks() throws { + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .user)) + } + let staleCid = ChannelId(type: .messaging, id: .unique) + try database.writeSynchronously { session in + let staleDTO = try session.saveChannel(payload: self.dummyPayload(with: staleCid)) + let queryDTO = session.saveQuery(query: ChannelListQuery(groupKey: "all")) + queryDTO.channels.insert(staleDTO) + } + let freshCid = ChannelId(type: .messaging, id: .unique) + let freshChannels = [self.dummyPayload(with: freshCid)] + + let pagination = GroupedChannelsPagination(groupKey: "all", next: nil, prev: nil) + let exp = expectation(description: "completion called") + listUpdater.queryGroupedChannels(groupPagination: pagination, limit: nil, watch: false, presence: false) { _ in exp.fulfill() } + let payload = GroupedQueryChannelsPayload( + groups: ["all": .init(channels: freshChannels, unreadChannels: 0)], + duration: "1ms" + ) + apiClient.test_simulateResponse(.success(payload)) + waitForExpectations(timeout: defaultTimeout) + + let linked = try XCTUnwrap(database.viewContext.channelListQuery(ChannelListQuery(groupKey: "all"))) + XCTAssertEqual(Set([freshCid.rawValue]), Set(linked.channels.map(\.cid))) + XCTAssertNil(linked.next) + } + + func test_queryGroupedChannels_persistsNextCursorOnQueryDTO() throws { + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .user)) + } + let exp = expectation(description: "completion called") + listUpdater.queryGroupedChannels(groupPagination: nil, limit: nil, watch: false, presence: false) { _ in exp.fulfill() } + let payload = GroupedQueryChannelsPayload( + groups: [ + "all": .init(channels: [], unreadChannels: 0, next: "all-next", prev: nil), + "exhausted": .init(channels: [], unreadChannels: 0, next: nil, prev: nil) + ], + duration: "1ms" + ) + apiClient.test_simulateResponse(.success(payload)) + waitForExpectations(timeout: defaultTimeout) + + let allLinked = try XCTUnwrap(database.viewContext.channelListQuery(ChannelListQuery(groupKey: "all"))) + let exhaustedLinked = try XCTUnwrap(database.viewContext.channelListQuery(ChannelListQuery(groupKey: "exhausted"))) + XCTAssertEqual("all-next", allLinked.next) + XCTAssertNil(exhaustedLinked.next) + } + + func test_queryGroupedChannels_paginatedContinuation_appendsToQueryDTOWithoutReset() throws { + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .user)) + } + let existingCid = ChannelId(type: .messaging, id: .unique) try database.writeSynchronously { session in let existingDTO = try session.saveChannel(payload: self.dummyPayload(with: existingCid)) - let queryDTO = session.saveQuery(query: query) + let queryDTO = session.saveQuery(query: ChannelListQuery(groupKey: "all")) queryDTO.channels.insert(existingDTO) - let newDTO = try session.saveChannel(payload: self.dummyPayload(with: newCid)) - newChannel = try newDTO.asModel() } + let appendedCid = ChannelId(type: .messaging, id: .unique) + let appendedChannels = [self.dummyPayload(with: appendedCid)] - let group = GroupedChannelsGroup( - groupKey: "all", - channels: [try XCTUnwrap(newChannel)], - unreadChannels: 0, - groupHandler: { key, _ in key } + let pagination = GroupedChannelsPagination(groupKey: "all", next: "cursor-1", prev: nil) + let exp = expectation(description: "completion called") + listUpdater.queryGroupedChannels(groupPagination: pagination, limit: nil, watch: false, presence: false) { _ in exp.fulfill() } + let payload = GroupedQueryChannelsPayload( + groups: ["all": .init(channels: appendedChannels, unreadChannels: 0)], + duration: "1ms" ) - let exp = expectation(description: "append completion") - listUpdater.appendToQuery(group: group, for: query) { _ in exp.fulfill() } + apiClient.test_simulateResponse(.success(payload)) waitForExpectations(timeout: defaultTimeout) - let queryDTO = try XCTUnwrap(database.viewContext.channelListQuery(query)) - let linkedCids = Set(queryDTO.channels.map(\.cid)) - XCTAssertEqual(Set([existingCid.rawValue, newCid.rawValue]), linkedCids) + let linked = try XCTUnwrap(database.viewContext.channelListQuery(ChannelListQuery(groupKey: "all"))) + XCTAssertEqual(Set([existingCid.rawValue, appendedCid.rawValue]), Set(linked.channels.map(\.cid))) } private func channels(for query: ChannelListQuery, database: DatabaseContainer) -> Set { From 1fc76e784514fe5e7fae3bf8fdfc8b6e07da3ea5 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 14 May 2026 15:52:34 +0300 Subject: [PATCH 31/31] Tidy code --- Sources/StreamChat/Workers/ChannelListUpdater.swift | 2 +- .../StreamChat/Controllers/ChatChannelListController_Mock.swift | 1 - .../ChannelListController/ChannelListController_Tests.swift | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Sources/StreamChat/Workers/ChannelListUpdater.swift b/Sources/StreamChat/Workers/ChannelListUpdater.swift index f1fd9d2c399..c2423382365 100644 --- a/Sources/StreamChat/Workers/ChannelListUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelListUpdater.swift @@ -255,7 +255,7 @@ class ChannelListUpdater: Worker, @unchecked Sendable { groupPagination: groupPagination, limit: limit, watch: watch, - presence: presence, + presence: presence ) { result in continuation.resume(with: result) } diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift index a13bb052a2d..abd142168fa 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift @@ -36,7 +36,6 @@ class ChatChannelListController_Mock: ChatChannelListController, Spy, @unchecked record() refreshLoadedChannelsResult.map(completion) } - } extension ChatChannelListController_Mock { diff --git a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift index 53888965d6d..babdec1e566 100644 --- a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift @@ -2111,7 +2111,6 @@ final class ChannelListController_Tests: XCTestCase { ) } - private func setupControllerWithFilter(_ filter: @escaping @Sendable (ChatChannel) -> Bool) { // Prepare controller controller = ChatChannelListController(