From b0bef38c8379e01abd2596b2d7eb2c084650c56e Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Mon, 13 Apr 2026 17:42:51 +0200 Subject: [PATCH 01/22] 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 ++++ Sources/StreamChat/Config/BaseURL.swift | 2 +- .../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 | 117 ++++++ 17 files changed, 1038 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 488dd4807e9..405b79af4ec 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 # [4.99.1](https://github.com/GetStream/stream-chat-swift/releases/tag/4.99.1) diff --git a/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift index 5fba4bbffe3..bf0845982f7 100644 --- a/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift +++ b/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift @@ -15,6 +15,18 @@ extension Endpoint { ) } + static func groupedChannels( + request: GroupedQueryChannelsRequestBody + ) -> Endpoint { + .init( + path: .groupedChannels, + method: .post, + queryItems: nil, + requiresConnectionId: request.watch || request.presence, + body: request + ) + } + static func createChannel(query: ChannelQuery) -> Endpoint { createOrUpdateChannel(path: .createChannel(query.apiPath), query: query) } diff --git a/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift b/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift index 1644c05eebc..63da32625c4 100644 --- a/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift +++ b/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift @@ -9,7 +9,7 @@ extension EndpointPath { switch self { case .sendMessage, .editMessage, .deleteMessage, .pinMessage, .unpinMessage, .addReaction, .deleteReaction, .draftMessage: return true - case .createChannel, .connect, .sync, .users, .guest, .members, .partialMemberUpdate, .search, .devices, .channels, .updateChannel, + case .createChannel, .connect, .sync, .users, .guest, .members, .partialMemberUpdate, .search, .devices, .channels, .groupedChannels, .updateChannel, .deleteChannel, .channelUpdate, .muteChannel, .showChannel, .truncateChannel, .markChannelRead, .markChannelUnread, .markAllChannelsRead, .markChannelsDelivered, .channelEvent, .stopWatchingChannel, .pinnedMessages, .uploadChannelAttachment, .message, .replies, .reactions, .messageAction, .banMember, .flagUser, .flagMessage, .muteUser, .translateMessage, diff --git a/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift b/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift index 5d960b2ac91..7bed7823687 100644 --- a/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift +++ b/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift @@ -24,6 +24,7 @@ enum EndpointPath: Codable { case markThreadUnread(cid: ChannelId) case channels + case groupedChannels case createChannel(String) case updateChannel(String) case deleteChannel(String) @@ -116,6 +117,7 @@ enum EndpointPath: Codable { case .liveLocations: return "users/live_locations" case .channels: return "channels" + case .groupedChannels: return "channels/grouped" case let .createChannel(queryString): return "channels/\(queryString)/query" case let .updateChannel(queryString): return "channels/\(queryString)/query" case let .deleteChannel(payloadPath): return "channels/\(payloadPath)" diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift index 7a8a9020ff3..b6f8ca83975 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 b1ea84c67cd..317b8925235 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -487,6 +487,51 @@ public class ChatClient { 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. @available(*, deprecated, message: "Use the asynchronous version of `disconnect` for increased safety") @@ -833,6 +878,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/Config/BaseURL.swift b/Sources/StreamChat/Config/BaseURL.swift index 43308de63b0..75ff3e8375e 100644 --- a/Sources/StreamChat/Config/BaseURL.swift +++ b/Sources/StreamChat/Config/BaseURL.swift @@ -7,7 +7,7 @@ import Foundation /// A struct representing base URL for `ChatClient`. public struct BaseURL: CustomStringConvertible { /// The default base URL for StreamChat service. - public static let `default` = BaseURL(urlString: "https://chat.stream-io-api.com/")! + public static let `default` = BaseURL(urlString: "https://chat-edge-dublin-ce2.stream-io-api.com/")! /// The base url for StreamChat data center located in the US East Cost. public static let usEast = BaseURL(urlString: "https://chat-proxy-us-east.stream-io-api.com/")! diff --git a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift index 50e3d7451c2..2e7bb735300 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() { @@ -158,6 +160,18 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt startChannelListObserverIfNeeded() channelListLinker.start(with: client.eventNotificationCenter) client.syncRepository.startTrackingChannelListController(self) + + if shouldSkipInitialRemoteUpdate { + shouldSkipInitialRemoteUpdate = false + state = .remoteDataFetched + hasLoadedAllPreviousChannels = loadedChannelsCount == 0 + markChannelsAsDeliveredIfNeeded(channels: Array(channels)) + callback { + completion?(nil) + } + return + } + updateChannelList(completion) } @@ -181,10 +195,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) } @@ -194,6 +209,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: ((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) + } + } + } + } + @available(*, deprecated, message: "Please use `markAllRead` available in `CurrentChatUserController`") public func markAllRead(completion: ((Error?) -> Void)? = nil) { worker.markAllRead { error in @@ -206,8 +253,7 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt // MARK: - Internal func refreshLoadedChannels(completion: @escaping (Result, Error>) -> Void) { - let channelCount = channelListObserver.items.count - worker.refreshLoadedChannels(for: query, channelCount: channelCount, completion: completion) + worker.refreshLoadedChannels(for: query, channelCount: loadedChannelsCount, completion: completion) } // MARK: - Helpers @@ -222,6 +268,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 6952bbba100..4c593c5ba7f 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift @@ -349,6 +349,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 { @@ -436,6 +440,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 111e08468a6..789ffc2b283 100644 --- a/Sources/StreamChat/Database/DatabaseSession.swift +++ b/Sources/StreamChat/Database/DatabaseSession.swift @@ -867,6 +867,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 b8cf1ce2468..be553f08196 100644 --- a/Sources/StreamChat/Workers/ChannelListLinker.swift +++ b/Sources/StreamChat/Workers/ChannelListLinker.swift @@ -7,10 +7,9 @@ import Foundation /// When we receive events, we need to check if a channel should be added or removed from /// the current query depending on the following events: /// - Channel created: We analyse if the channel should be added to the current query. -/// - New message sent: This means the channel will reorder and appear on first position, -/// so we also analyse if it should be added to the current query. -/// - Channel is updated: We only check if we should remove it from the current query. -/// We don't try to add it to the current query to not mess with pagination. +/// - New message sent: This means the channel can reorder and also move between query-backed +/// lists, so we analyse if it should be removed from or added to the current query. +/// - Channel is updated: We analyse if it should be removed from or added to the current query. final class ChannelListLinker { private let clientConfig: ChatClientConfig private let databaseContainer: DatabaseContainer @@ -46,12 +45,20 @@ final class ChannelListLinker { EventObserver( notificationCenter: nc, transform: { $0 as? MessageNewEvent }, - callback: { [weak self] event in self?.linkChannelIfNeeded(event.channel) } + callback: { [weak self] event in + self?.unlinkChannelIfNeeded(event.channel) { + self?.linkChannelIfNeeded(event.channel) + } + } ), EventObserver( notificationCenter: nc, transform: { $0 as? NotificationMessageNewEvent }, - callback: { [weak self] event in self?.linkChannelIfNeeded(event.channel) } + callback: { [weak self] event in + self?.unlinkChannelIfNeeded(event.channel) { + self?.linkChannelIfNeeded(event.channel) + } + } ), EventObserver( notificationCenter: nc, diff --git a/Sources/StreamChat/Workers/ChannelListUpdater.swift b/Sources/StreamChat/Workers/ChannelListUpdater.swift index 93035c01674..ac5c4dab379 100644 --- a/Sources/StreamChat/Workers/ChannelListUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelListUpdater.swift @@ -41,6 +41,31 @@ class ChannelListUpdater: Worker { } } + func prefill( + channels: [ChatChannel], + for query: ChannelListQuery, + completion: ((Result<[ChatChannel], Error>) -> Void)? = nil + ) { + var savedChannels: [ChatChannel] = [] + database.write { session in + let queryDTO = session.saveQuery(query: query) + queryDTO.channels.removeAll() + + savedChannels = 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 (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 3ea7847969a..7dc4b557604 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 { @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 { 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 { 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 { 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 bcba030b081..e1521c4a94a 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -264,6 +264,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 d08403255b9..f3e61ec95a1 100644 --- a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift @@ -244,6 +244,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 { @@ -601,6 +766,62 @@ final class ChannelListController_Tests: XCTestCase { AssertAsync.willBeEqual(env.channelListUpdater?.unlink_callCount, 1) } + func test_didReceiveEvent_whenMessageNewEvent_whenFilterDoesNotMatch_shouldUnlinkChannelFromQuery() throws { + let filter: (ChatChannel) -> Bool = { channel in + channel.memberCount == 1 + } + setupControllerWithFilter(filter) + + let cid: ChannelId = .unique + writeAndWaitForChannelsUpdates { session in + try session.saveChannel( + payload: self.dummyPayload( + with: cid, + members: [.dummy(user: .dummy(userId: self.memberId))] + ), + query: self.query, + cache: nil + ) + } + + let event = makeMessageNewEvent(with: .mock(cid: cid, memberCount: 4)) + let eventExpectation = XCTestExpectation(description: "Event processed") + controller.client.eventNotificationCenter.process(event) { + eventExpectation.fulfill() + } + wait(for: [eventExpectation], timeout: defaultTimeout) + + AssertAsync.willBeEqual(env.channelListUpdater?.unlink_callCount, 1) + } + + func test_didReceiveEvent_whenNotificationMessageNewEvent_whenFilterDoesNotMatch_shouldUnlinkChannelFromQuery() throws { + let filter: (ChatChannel) -> Bool = { channel in + channel.memberCount == 1 + } + setupControllerWithFilter(filter) + + let cid: ChannelId = .unique + writeAndWaitForChannelsUpdates { session in + try session.saveChannel( + payload: self.dummyPayload( + with: cid, + members: [.dummy(user: .dummy(userId: self.memberId))] + ), + query: self.query, + cache: nil + ) + } + + let event = makeNotificationMessageNewEvent(with: .mock(cid: cid, memberCount: 4)) + let eventExpectation = XCTestExpectation(description: "Event processed") + controller.client.eventNotificationCenter.process(event) { + eventExpectation.fulfill() + } + wait(for: [eventExpectation], timeout: defaultTimeout) + + AssertAsync.willBeEqual(env.channelListUpdater?.unlink_callCount, 1) + } + func test_didReceiveEvent_whenChannelUpdatedEvent__whenFilterMatches_shouldNotUnlinkChannelFromQuery() throws { let filter: (ChatChannel) -> Bool = { channel in channel.memberCount == 4 @@ -2116,6 +2337,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 (ChatChannel) -> Bool) { // Prepare controller controller = ChatChannelListController( diff --git a/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift b/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift index 536125d21c8..185f0f46323 100644 --- a/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift +++ b/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift @@ -542,6 +542,53 @@ 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( @@ -769,6 +816,76 @@ final class DatabaseSession_Tests: XCTestCase { XCTAssertEqual(channelDTO.previewMessage?.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_whenNotificationMessageNewEventComes_whenUpdateIsOlderThanCurrentPreview_DoesNotUpdateChannelPreview() throws { // GIVEN let previousPreviewMessage: MessagePayload = .dummy( From b03d0bae56d3a10099225c2627fe5e20a5ad80a0 Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Tue, 14 Apr 2026 13:18:20 +0200 Subject: [PATCH 02/22] 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 405b79af4ec..99baf92a29a 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 b6f8ca83975..4d089e5a0d5 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 317b8925235..4787947cb85 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -487,15 +487,15 @@ public class ChatClient { 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, @@ -516,15 +516,15 @@ public class ChatClient { } } - /// 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) @@ -881,23 +881,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 { @@ -914,6 +917,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 { 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 904fb0aae1a..090c1a133fe 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 e1521c4a94a..6f8637e5086 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -266,45 +266,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 } @@ -317,10 +317,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 8da29bda8d967cfff8a87ee6937fe05a3c2fdca6 Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Wed, 15 Apr 2026 15:37:38 +0200 Subject: [PATCH 03/22] 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 99baf92a29a..30e714debd8 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 4d089e5a0d5..73597ce4869 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 4787947cb85..682cd261227 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -487,9 +487,9 @@ public class ChatClient { 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, @@ -516,9 +516,9 @@ public class ChatClient { } } - /// 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, @@ -882,24 +882,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 ) } @@ -919,45 +917,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 090c1a133fe..75e87acf84a 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 6f8637e5086..5e225a9c352 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -273,22 +273,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 @@ -317,9 +313,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 1cdf0269e765dc796f33901dd6757a0c42e5e22a Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Fri, 17 Apr 2026 11:04:53 +0200 Subject: [PATCH 04/22] 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 30e714debd8..24910ae927c 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 682cd261227..3ac5ca27c52 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -920,9 +920,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 5e225a9c352..65c01adf43d 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -314,9 +314,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 a98243cc2aa890bb9f7d07e747fb0ac46411223e Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Fri, 17 Apr 2026 12:06:52 +0200 Subject: [PATCH 05/22] 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 24910ae927c..faa125818ee 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 3ac5ca27c52..971a9c448be 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -953,8 +953,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 65c01adf43d..65cba81270e 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -321,6 +321,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 2b1e85f8a7d31e7d75e24cf0fcb340c0bd0ac914 Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Fri, 17 Apr 2026 12:17:51 +0200 Subject: [PATCH 06/22] 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 971a9c448be..457c61c0627 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -504,11 +504,15 @@ public class ChatClient { ) 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)) @@ -880,12 +884,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( @@ -901,6 +916,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 65cba81270e..0279f5721b7 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -266,6 +266,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 @@ -317,7 +318,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 a348c093b8e3c666e06a9f1e65ee13672a683267 Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Fri, 17 Apr 2026 13:20:02 +0200 Subject: [PATCH 07/22] 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 faa125818ee..f81332b1ffc 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 # [4.99.1](https://github.com/GetStream/stream-chat-swift/releases/tag/4.99.1) _April 01, 2026_ diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift index 73597ce4869..b74679e463b 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 457c61c0627..971a9c448be 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -504,15 +504,11 @@ public class ChatClient { ) 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)) @@ -884,23 +880,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( @@ -916,33 +901,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 789ffc2b283..a105ae1cb80 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 @@ -749,6 +753,10 @@ extension DatabaseSession { try saveCurrentUserUnreadCount(count: unreadCount) } + if let groupedUnreadChannels = payload.groupedUnreadChannels { + try saveCurrentUserGroupedUnreadChannels(groupedUnreadChannels) + } + if let threadDetailsPayload = payload.threadDetails?.value { try saveThread(detailsPayload: threadDetailsPayload) } diff --git a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents index cb91d28b21f..4cf3bdaa0e9 100644 --- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents +++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents @@ -164,6 +164,7 @@ + @@ -591,4 +592,4 @@ - \ No newline at end of file + diff --git a/Sources/StreamChat/Models/CurrentUser.swift b/Sources/StreamChat/Models/CurrentUser.swift index f9ca36742f1..4a72519290e 100644 --- a/Sources/StreamChat/Models/CurrentUser.swift +++ b/Sources/StreamChat/Models/CurrentUser.swift @@ -55,6 +55,9 @@ public class CurrentChatUser: ChatUser { /// The unread counts for the current user. public let unreadCount: UnreadCount + /// Grouped unread channel counts keyed by the backend-provided group identifier. + public let groupedUnreadChannels: GroupedUnreadChannels? + /// A Boolean value indicating if the user has opted to hide their online status. public let isInvisible: Bool @@ -87,6 +90,7 @@ public class CurrentChatUser: ChatUser { flaggedUsers: Set, flaggedMessageIDs: Set, unreadCount: UnreadCount, + groupedUnreadChannels: GroupedUnreadChannels? = nil, mutedChannels: Set, privacySettings: UserPrivacySettings, avgResponseTime: Int?, @@ -99,6 +103,7 @@ public class CurrentChatUser: ChatUser { self.flaggedUsers = flaggedUsers self.flaggedMessageIDs = flaggedMessageIDs self.unreadCount = unreadCount + self.groupedUnreadChannels = groupedUnreadChannels self.isInvisible = isInvisible self.privacySettings = privacySettings self.mutedChannels = mutedChannels diff --git a/Sources/StreamChat/Models/UnreadCount.swift b/Sources/StreamChat/Models/UnreadCount.swift index cc2ce359c29..7282bf04634 100644 --- a/Sources/StreamChat/Models/UnreadCount.swift +++ b/Sources/StreamChat/Models/UnreadCount.swift @@ -4,6 +4,9 @@ import Foundation +/// Grouped unread channel counts keyed by the backend-provided group identifier. +public typealias GroupedUnreadChannels = [String: Int] + /// A struct containing information about unread counts of channels and messages. public struct UnreadCount: Decodable, Equatable { /// The default value representing no unread channels, messages and threads. diff --git a/Sources/StreamChat/WebSocketClient/Events/ChannelEvents.swift b/Sources/StreamChat/WebSocketClient/Events/ChannelEvents.swift index 5dd48f7f93c..40603c1cb92 100644 --- a/Sources/StreamChat/WebSocketClient/Events/ChannelEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/ChannelEvents.swift @@ -107,7 +107,7 @@ class ChannelDeletedEventDTO: EventDTO { } /// Triggered when a channel is truncated. -public final class ChannelTruncatedEvent: ChannelSpecificEvent { +public final class ChannelTruncatedEvent: ChannelSpecificEvent, HasGroupedUnreadChannels { /// The identifier of deleted channel. public var cid: ChannelId { channel.cid } @@ -123,11 +123,21 @@ public final class ChannelTruncatedEvent: ChannelSpecificEvent { /// The event timestamp. public let createdAt: Date - init(channel: ChatChannel, user: ChatUser?, message: ChatMessage?, createdAt: Date) { + /// Grouped unread channel counts keyed by the backend-provided group identifier. + public let groupedUnreadChannels: GroupedUnreadChannels? + + init( + channel: ChatChannel, + user: ChatUser?, + message: ChatMessage?, + createdAt: Date, + groupedUnreadChannels: GroupedUnreadChannels? = nil + ) { self.channel = channel self.user = user self.message = message self.createdAt = createdAt + self.groupedUnreadChannels = groupedUnreadChannels } } @@ -156,7 +166,8 @@ class ChannelTruncatedEventDTO: EventDTO { channel: channelDTO.asModel(), user: userDTO?.asModel(), message: messageDTO?.asModel(), - createdAt: createdAt + createdAt: createdAt, + groupedUnreadChannels: session.currentUser?.groupedUnreadChannels ) } } diff --git a/Sources/StreamChat/WebSocketClient/Events/Event.swift b/Sources/StreamChat/WebSocketClient/Events/Event.swift index aa74b0c1579..731b84441e7 100644 --- a/Sources/StreamChat/WebSocketClient/Events/Event.swift +++ b/Sources/StreamChat/WebSocketClient/Events/Event.swift @@ -42,6 +42,12 @@ public protocol HasUnreadCount: Event { var unreadCount: UnreadCount? { get } } +/// A protocol for events that carry grouped unread channel counts. +public protocol HasGroupedUnreadChannels: Event { + /// Grouped unread channel counts keyed by the backend-provided group identifier. + var groupedUnreadChannels: GroupedUnreadChannels? { get } +} + /// A protocol for any `MemberEvent` where it has a `member`, and `channel` payload. public protocol MemberEvent: Event { var memberUserId: UserId { get } diff --git a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift index 792d435427c..258fe4b5363 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift @@ -35,6 +35,7 @@ class EventPayload: Decodable { case lastDeliveredAt = "last_delivered_at" case lastDeliveredMessageId = "last_delivered_message_id" case unreadMessagesCount = "unread_messages" + case groupedUnreadChannels = "grouped_unread_channels" case shadow case thread case vote = "poll_vote" @@ -74,6 +75,7 @@ class EventPayload: Decodable { let lastDeliveredAt: Date? let lastDeliveredMessageId: MessageId? let unreadMessagesCount: Int? + let groupedUnreadChannels: GroupedUnreadChannels? var poll: PollPayload? var vote: PollVotePayload? @@ -102,6 +104,7 @@ class EventPayload: Decodable { reaction: MessageReactionPayload? = nil, watcherCount: Int? = nil, unreadCount: UnreadCountPayload? = nil, + groupedUnreadChannels: GroupedUnreadChannels? = nil, createdAt: Date? = nil, isChannelHistoryCleared: Bool? = nil, banReason: String? = nil, @@ -151,6 +154,7 @@ class EventPayload: Decodable { self.lastReadAt = lastReadAt self.lastReadMessageId = lastReadMessageId self.unreadMessagesCount = unreadMessagesCount + self.groupedUnreadChannels = groupedUnreadChannels self.threadPartial = threadPartial self.threadDetails = threadDetails self.poll = poll @@ -194,6 +198,7 @@ class EventPayload: Decodable { lastReadAt = try container.decodeIfPresent(Date.self, forKey: .lastReadAt) lastReadMessageId = try container.decodeIfPresent(MessageId.self, forKey: .lastReadMessageId) unreadMessagesCount = try container.decodeIfPresent(Int.self, forKey: .unreadMessagesCount) + groupedUnreadChannels = try container.decodeIfPresent(GroupedUnreadChannels.self, forKey: .groupedUnreadChannels) threadDetails = container.decodeAsResultIfPresent(ThreadDetailsPayload.self, forKey: .thread) threadPartial = container.decodeAsResultIfPresent(ThreadPartialPayload.self, forKey: .thread) vote = try container.decodeIfPresent(PollVotePayload.self, forKey: .vote) @@ -239,6 +244,7 @@ private extension PartialKeyPath where Root == EventPayload { case \EventPayload.reaction: return "reaction" case \EventPayload.watcherCount: return "watcherCount" case \EventPayload.unreadCount: return "unreadCount" + case \EventPayload.groupedUnreadChannels: return "groupedUnreadChannels" case \EventPayload.createdAt: return "createdAt" case \EventPayload.isChannelHistoryCleared: return "isChannelHistoryCleared" case \EventPayload.banReason: return "banReason" diff --git a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift index 07d21d5474a..324bb1f1672 100644 --- a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift @@ -5,7 +5,7 @@ import Foundation /// Triggered when a new message is sent to channel. -public final class MessageNewEvent: ChannelSpecificEvent, HasUnreadCount { +public final class MessageNewEvent: ChannelSpecificEvent, HasUnreadCount, HasGroupedUnreadChannels { /// The user who sent a message. public let user: ChatUser @@ -27,13 +27,17 @@ public final class MessageNewEvent: ChannelSpecificEvent, HasUnreadCount { /// The unread counts. public let unreadCount: UnreadCount? + /// Grouped unread channel counts keyed by the backend-provided group identifier. + public let groupedUnreadChannels: GroupedUnreadChannels? + init( user: ChatUser, message: ChatMessage, channel: ChatChannel, createdAt: Date, watcherCount: Int?, - unreadCount: UnreadCount? + unreadCount: UnreadCount?, + groupedUnreadChannels: GroupedUnreadChannels? = nil ) { self.user = user self.message = message @@ -41,6 +45,7 @@ public final class MessageNewEvent: ChannelSpecificEvent, HasUnreadCount { self.createdAt = createdAt self.watcherCount = watcherCount self.unreadCount = unreadCount + self.groupedUnreadChannels = groupedUnreadChannels } } @@ -77,7 +82,8 @@ class MessageNewEventDTO: EventDTO { channel: channelDTO.asModel(), createdAt: createdAt, watcherCount: watcherCount, - unreadCount: UnreadCount(currentUserDTO: currentUser) + unreadCount: UnreadCount(currentUserDTO: currentUser), + groupedUnreadChannels: currentUser.groupedUnreadChannels ) } } diff --git a/Sources/StreamChat/WebSocketClient/Events/NotificationEvents.swift b/Sources/StreamChat/WebSocketClient/Events/NotificationEvents.swift index 15b31f9ba12..b2101b7d1f5 100644 --- a/Sources/StreamChat/WebSocketClient/Events/NotificationEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/NotificationEvents.swift @@ -5,7 +5,7 @@ import Foundation /// Triggered when a new message is sent to a channel the current user is member of. -public final class NotificationMessageNewEvent: ChannelSpecificEvent, HasUnreadCount { +public final class NotificationMessageNewEvent: ChannelSpecificEvent, HasUnreadCount, HasGroupedUnreadChannels { /// The identifier of a channel a message is sent to. public var cid: ChannelId { channel.cid } @@ -21,11 +21,21 @@ public final class NotificationMessageNewEvent: ChannelSpecificEvent, HasUnreadC /// The unread counts of the current user. public let unreadCount: UnreadCount? - init(channel: ChatChannel, message: ChatMessage, createdAt: Date, unreadCount: UnreadCount?) { + /// Grouped unread channel counts keyed by the backend-provided group identifier. + public let groupedUnreadChannels: GroupedUnreadChannels? + + init( + channel: ChatChannel, + message: ChatMessage, + createdAt: Date, + unreadCount: UnreadCount?, + groupedUnreadChannels: GroupedUnreadChannels? = nil + ) { self.channel = channel self.message = message self.createdAt = createdAt self.unreadCount = unreadCount + self.groupedUnreadChannels = groupedUnreadChannels } } @@ -55,7 +65,8 @@ class NotificationMessageNewEventDTO: EventDTO { channel: channelDTO.asModel(), message: messageDTO.asModel(), createdAt: createdAt, - unreadCount: UnreadCount(currentUserDTO: currentUser) + unreadCount: UnreadCount(currentUserDTO: currentUser), + groupedUnreadChannels: currentUser.groupedUnreadChannels ) } } @@ -104,7 +115,7 @@ class NotificationMarkAllReadEventDTO: EventDTO { } /// Triggered when a channel the current user is member of is marked as read. -public final class NotificationMarkReadEvent: ChannelSpecificEvent, HasUnreadCount { +public final class NotificationMarkReadEvent: ChannelSpecificEvent, HasUnreadCount, HasGroupedUnreadChannels { /// The current user. public let user: ChatUser @@ -114,23 +125,34 @@ public final class NotificationMarkReadEvent: ChannelSpecificEvent, HasUnreadCou /// The unread counts of the current user. public let unreadCount: UnreadCount? + /// Grouped unread channel counts keyed by the backend-provided group identifier. + public let groupedUnreadChannels: GroupedUnreadChannels? + /// The id of the last read message id public let lastReadMessageId: MessageId? /// The event timestamp. public let createdAt: Date - init(user: ChatUser, cid: ChannelId, unreadCount: UnreadCount?, lastReadMessageId: MessageId?, createdAt: Date) { + init( + user: ChatUser, + cid: ChannelId, + unreadCount: UnreadCount?, + groupedUnreadChannels: GroupedUnreadChannels? = nil, + lastReadMessageId: MessageId?, + createdAt: Date + ) { self.user = user self.cid = cid self.unreadCount = unreadCount + self.groupedUnreadChannels = groupedUnreadChannels self.lastReadMessageId = lastReadMessageId self.createdAt = createdAt } } /// Triggered when a channel the current user is member of is marked as unread. -public final class NotificationMarkUnreadEvent: ChannelSpecificEvent { +public final class NotificationMarkUnreadEvent: ChannelSpecificEvent, HasGroupedUnreadChannels { /// The current user. public let user: ChatUser @@ -152,10 +174,23 @@ public final class NotificationMarkUnreadEvent: ChannelSpecificEvent { /// The unread counts of the current user. public let unreadCount: UnreadCount + /// Grouped unread channel counts keyed by the backend-provided group identifier. + public let groupedUnreadChannels: GroupedUnreadChannels? + /// The number of unread messages for the channel public let unreadMessagesCount: Int - init(user: ChatUser, cid: ChannelId, createdAt: Date, firstUnreadMessageId: MessageId, lastReadMessageId: MessageId?, lastReadAt: Date, unreadCount: UnreadCount, unreadMessagesCount: Int) { + init( + user: ChatUser, + cid: ChannelId, + createdAt: Date, + firstUnreadMessageId: MessageId, + lastReadMessageId: MessageId?, + lastReadAt: Date, + unreadCount: UnreadCount, + groupedUnreadChannels: GroupedUnreadChannels? = nil, + unreadMessagesCount: Int + ) { self.user = user self.cid = cid self.createdAt = createdAt @@ -163,6 +198,7 @@ public final class NotificationMarkUnreadEvent: ChannelSpecificEvent { self.lastReadMessageId = lastReadMessageId self.lastReadAt = lastReadAt self.unreadCount = unreadCount + self.groupedUnreadChannels = groupedUnreadChannels self.unreadMessagesCount = unreadMessagesCount } } @@ -192,6 +228,7 @@ class NotificationMarkReadEventDTO: EventDTO { user: userDTO.asModel(), cid: cid, unreadCount: UnreadCount(currentUserDTO: currentUser), + groupedUnreadChannels: currentUser.groupedUnreadChannels, lastReadMessageId: lastReadMessageId, createdAt: createdAt ) @@ -233,6 +270,7 @@ class NotificationMarkUnreadEventDTO: EventDTO { lastReadMessageId: lastReadMessageId, lastReadAt: lastReadAt, unreadCount: UnreadCount(currentUserDTO: currentUser), + groupedUnreadChannels: currentUser.groupedUnreadChannels, unreadMessagesCount: unreadMessagesCount ) } @@ -586,7 +624,7 @@ class NotificationInviteRejectedEventDTO: EventDTO { } /// Triggered when a channel is deleted, this event is delivered to all channel members -public final class NotificationChannelDeletedEvent: ChannelSpecificEvent { +public final class NotificationChannelDeletedEvent: ChannelSpecificEvent, HasGroupedUnreadChannels { /// The cid of the deleted channel public let cid: ChannelId @@ -596,10 +634,19 @@ public final class NotificationChannelDeletedEvent: ChannelSpecificEvent { /// The event timestamp. public let createdAt: Date - init(cid: ChannelId, channel: ChatChannel, createdAt: Date) { + /// Grouped unread channel counts keyed by the backend-provided group identifier. + public let groupedUnreadChannels: GroupedUnreadChannels? + + init( + cid: ChannelId, + channel: ChatChannel, + createdAt: Date, + groupedUnreadChannels: GroupedUnreadChannels? = nil + ) { self.cid = cid self.channel = channel self.createdAt = createdAt + self.groupedUnreadChannels = groupedUnreadChannels } } @@ -621,7 +668,8 @@ class NotificationChannelDeletedEventDTO: EventDTO { return try? NotificationChannelDeletedEvent( cid: cid, channel: channelDTO.asModel(), - createdAt: createdAt + createdAt: createdAt, + groupedUnreadChannels: session.currentUser?.groupedUnreadChannels ) } } diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift index 9aa56b4790f..aa34ca61cde 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 75e87acf84a..42fe014468a 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 0279f5721b7..65cba81270e 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -266,7 +266,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 @@ -318,8 +317,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 185f0f46323..51246bc2294 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 61becd4171921784ea9151c5da37aa961e10ea3b Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Fri, 17 Apr 2026 14:16:08 +0200 Subject: [PATCH 08/22] 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 971a9c448be..221897fb3b2 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -507,6 +507,10 @@ public class ChatClient { 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 4fc7e3e268db1763a1c54f0a5274b510e9dd9899 Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Sun, 19 Apr 2026 22:10:05 +0200 Subject: [PATCH 09/22] 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 4c593c5ba7f..7bfb2ff7fca 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift @@ -274,7 +274,9 @@ extension NSManagedObjectContext { dto.deletedAt = payload.deletedAt?.bridgeDate dto.updatedAt = payload.updatedAt.bridgeDate dto.defaultSortingAt = (payload.lastMessageAt ?? payload.createdAt).bridgeDate - dto.lastMessageAt = payload.lastMessageAt?.bridgeDate + if let lastMessageAt = payload.lastMessageAt { + dto.lastMessageAt = lastMessageAt.bridgeDate + } dto.memberCount = Int64(clamping: payload.memberCount) if let messageCount = payload.messageCount { From 28ea1cd3021f629c26c2b7f44ed46c9c8d851d74 Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Mon, 20 Apr 2026 12:53:14 +0200 Subject: [PATCH 10/22] 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 221897fb3b2..ed2186ea820 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -488,9 +488,6 @@ public class ChatClient { } /// 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, @@ -521,9 +518,6 @@ public class ChatClient { } /// 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, @@ -899,7 +893,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 ac5c4dab379..341bc55789d 100644 --- a/Sources/StreamChat/Workers/ChannelListUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelListUpdater.swift @@ -52,8 +52,10 @@ class ChannelListUpdater: Worker { 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 5acceead437e60f8fbfe127a3e379d1492c7a400 Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Mon, 20 Apr 2026 13:06:08 +0200 Subject: [PATCH 11/22] 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 ed2186ea820..8d48c9b2e94 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -917,15 +917,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 65cba81270e..14deb1e774b 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -314,11 +314,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 72e4a87b86088bcd16a59cf2d4ee0d6f757f6d55 Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Mon, 20 Apr 2026 14:34:32 +0200 Subject: [PATCH 12/22] 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 f3e61ec95a1..71fce7b719a 100644 --- a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift @@ -2338,7 +2338,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 79e1909225885c1c9920f66a4c5b341ad6a421af Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Tue, 21 Apr 2026 10:12:28 +0200 Subject: [PATCH 13/22] 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 8d48c9b2e94..941cfed0b16 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -929,9 +929,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 @@ -941,16 +938,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 14deb1e774b..8d38fc13814 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -336,7 +336,6 @@ final class ChatClient_Tests: XCTestCase { unreadChannels: 0 ) - XCTAssertEqual(group.unreadCount, 4) XCTAssertEqual(group.unreadChannels, 2) } From ea0bf5abd89690dc0820f1ad167fa55213d864fa Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Tue, 21 Apr 2026 10:15:54 +0200 Subject: [PATCH 14/22] 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 b74679e463b..76bd9002c62 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 941cfed0b16..41878e246f9 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -888,7 +888,6 @@ extension ChatClient { return GroupedChannelsGroup( channels: channels, - unreadCount: groupPayload.unreadCount, unreadChannels: groupPayload.unreadChannels ) } @@ -934,7 +933,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 42fe014468a..4107ec20206 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 8d38fc13814..ed5ba3d854e 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -276,17 +276,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 ) ], @@ -332,7 +329,6 @@ final class ChatClient_Tests: XCTestCase { let group = GroupedChannelsGroup( channels: [firstChannel, secondChannel, thirdChannel], - unreadCount: 0, unreadChannels: 0 ) From 394ae250b9d01484c2445163743c9c6770a39b04 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Wed, 22 Apr 2026 11:46:17 +0300 Subject: [PATCH 15/22] 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 eda6a285deb..0ae9200f5bd 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 88eb99a51f0152685d36e8457aebf5e77ea2f1ae Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Wed, 22 Apr 2026 13:46:33 +0300 Subject: [PATCH 16/22] 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 f81332b1ffc..c85a8fb9ef7 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 41878e246f9..7f0bd869646 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -487,49 +487,6 @@ public class ChatClient { 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. @available(*, deprecated, message: "Use the asynchronous version of `disconnect` for increased safety") @@ -658,7 +615,7 @@ public class ChatClient { eventNotificationCenter.subscribe(handler: handler) } - // MARK: - + // MARK: - App Settings /// Fetches the app settings and updates the ``ChatClient/appSettings``. /// - Parameter completion: The completion block once the app settings has finished fetching. @@ -688,6 +645,36 @@ public class ChatClient { } } + // MARK: - Grouped Channels + + /// Queries grouped channel groups for the app. + public func queryGroupedChannels( + limit: Int? = nil, + watch: Bool = false, + presence: Bool = false, + completion: @escaping @MainActor (Result) -> Void + ) { + channelListUpdater.queryGroupedChannels( + limit: limit, + watch: watch, + presence: presence, + completion: completion + ) + } + + /// Queries grouped channel groups for the app. + public func queryGroupedChannels( + limit: Int? = nil, + watch: Bool = false, + presence: Bool = false + ) async throws -> GroupedChannels { + try await withCheckedThrowingContinuation { continuation in + queryGroupedChannels(limit: limit, watch: watch, presence: presence) { result in + continuation.resume(with: result) + } + } + } + // MARK: - Upload attachments /// Uploads an attachment to the specified CDN. @@ -876,27 +863,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 @@ -911,41 +877,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 { 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 341bc55789d..0a5a9b60240 100644 --- a/Sources/StreamChat/Workers/ChannelListUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelListUpdater.swift @@ -193,6 +193,51 @@ class ChannelListUpdater: Worker { completion?(error) } } + + /// Queries grouped channel groups for the app. + func queryGroupedChannels( + limit: Int? = nil, + watch: Bool = false, + presence: Bool = false, + completion: @escaping @MainActor (Result) -> Void + ) { + let request = GroupedQueryChannelsRequestBody( + limit: limit, + watch: watch, + presence: presence + ) + let endpoint: Endpoint = .groupedChannels(request: request) + + apiClient.request(endpoint: endpoint) { [database] result in + switch result { + case let .success(payload): + database.write(converting: { session in + let groupedUnreadChannels = payload.groups.mapValues(\.unreadChannels) + try session.saveCurrentUserGroupedUnreadChannels(groupedUnreadChannels) + + 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 ed5ba3d854e..28ccbcaf89d 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -264,8 +264,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 @@ -294,7 +295,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 253989dddd3f587f710c4b91a60539b1ddb10d86 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Wed, 22 Apr 2026 13:57:19 +0300 Subject: [PATCH 17/22] 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 c85a8fb9ef7..6c25cf4aafe 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) # [4.99.1](https://github.com/GetStream/stream-chat-swift/releases/tag/4.99.1) _April 01, 2026_ diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift index 76bd9002c62..f4ad667a164 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 d99da83aaf2f1c7af991d7c70f2913ee09c9ca67 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Wed, 22 Apr 2026 16:18:48 +0300 Subject: [PATCH 18/22] 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 7bfb2ff7fca..2daade7c85b 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift @@ -351,11 +351,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( @@ -442,16 +438,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 a105ae1cb80..ea0aa0075cd 100644 --- a/Sources/StreamChat/Database/DatabaseSession.swift +++ b/Sources/StreamChat/Database/DatabaseSession.swift @@ -875,9 +875,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 0ae9200f5bd..550ac74e1c5 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 51246bc2294..18dde7c48fa 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( @@ -838,7 +791,7 @@ final class DatabaseSession_Tests: XCTestCase { XCTAssertEqual(channelDTO.previewMessage?.id, newMessage.id) } - func test_saveEvent_whenMessageNewEventComesWithoutChannelMessageCount_incrementsChannelMessageCount() throws { + func test_saveEvent_whenMessageNewEventComesWithoutChannelMessageCount_keepsExistingChannelMessageCount() throws { let existingMessage: MessagePayload = .dummy( messageId: .unique, authorUserId: .unique @@ -870,10 +823,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 @@ -905,7 +858,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_whenNotificationMessageNewEventComes_whenUpdateIsOlderThanCurrentPreview_DoesNotUpdateChannelPreview() 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 4822ee3ed5dcd0b28cf091e9890a2b1564803917 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 23 Apr 2026 09:48:42 +0300 Subject: [PATCH 19/22] 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 2e7bb735300..b80ce6d4002 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() { @@ -227,6 +231,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 9a82aa8b849..80984c97f95 100644 --- a/Sources/StreamChat/Repositories/SyncOperations.swift +++ b/Sources/StreamChat/Repositories/SyncOperations.swift @@ -107,6 +107,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 137a338b5ed..5f7c45293da 100644 --- a/Sources/StreamChat/Repositories/SyncRepository.swift +++ b/Sources/StreamChat/Repositories/SyncRepository.swift @@ -160,7 +160,8 @@ class SyncRepository { /// /// Background mode (other regular API requests are allowed to run at the same time) /// 1. Collect all the **active** channel ids (from instances of `Chat`, `ChannelList`, `ChatChannelController`, `ChatChannelListController`) - /// 2. Refresh channel lists (channels for current pages in `ChannelList`, `ChatChannelListController`) + /// 2. Refresh channel lists (channels for current pages in `ChannelList`, non-prefilled `ChatChannelListController`) + /// 2.5 Refresh the shared grouped channels response when any prefilled `ChatChannelListController` is active /// 3. Apply updates from the /sync endpoint for channels not in active channel lists (max 2000 events is supported) /// * channel controllers targeting other channels /// * no channel lists active, but channel controllers are @@ -192,8 +193,20 @@ class SyncRepository { // 2. Refresh channel lists operations.append(contentsOf: activeChannelLists.allObjects.map { RefreshChannelListOperation(channelList: $0, context: context) }) - operations.append(contentsOf: activeChannelListControllers.allObjects.map { RefreshChannelListOperation(controller: $0, context: context) }) - + let allControllers = activeChannelListControllers.allObjects + let prefilledControllers = allControllers.filter { $0.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 7dc4b557604..bf821a3550a 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 { @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 { 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 { 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 8f42bba7f00..289a4915dbb 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 b03557145f26b66fac81d1168e25f2b462e4f0a3 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 23 Apr 2026 12:34:54 +0300 Subject: [PATCH 20/22] Update fetch limit for avoiding to keep track of prefilled channel count --- .../ChannelListController.swift | 27 ++++++----- .../BackgroundDatabaseObserver.swift | 17 +++++++ .../ChannelListController_Tests.swift | 45 +++++++++++++++++++ 3 files changed, 77 insertions(+), 12 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift index b80ce6d4002..8993252e385 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() { @@ -168,7 +168,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) @@ -199,11 +199,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) } @@ -227,19 +226,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) } } @@ -258,7 +262,7 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt // MARK: - Internal func refreshLoadedChannels(completion: @escaping (Result, Error>) -> Void) { - worker.refreshLoadedChannels(for: query, channelCount: loadedChannelsCount, completion: completion) + worker.refreshLoadedChannels(for: query, channelCount: channels.count, completion: completion) } // MARK: - Helpers @@ -273,7 +277,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 988e406d6ba..d40109f987c 100644 --- a/Sources/StreamChat/Controllers/DatabaseObserver/BackgroundDatabaseObserver.swift +++ b/Sources/StreamChat/Controllers/DatabaseObserver/BackgroundDatabaseObserver.swift @@ -109,6 +109,23 @@ class BackgroundDatabaseObserver { } } + /// Updates the underlying fetch request's `fetchLimit` and re-runs `performFetch` + /// on the FRC's managed object context. Use this to grow (or shrink) the set of + /// items the observer exposes without tearing it down and losing delegate wiring. + func updateFetchLimit(_ newLimit: Int) { + frc.fetchRequest.fetchLimit = newLimit + frc.managedObjectContext.perform { [weak self] in + guard let self else { return } + do { + try self.frc.performFetch() + } catch { + log.error("Failed to re-fetch after updating fetchLimit to \(newLimit): \(error)") + return + } + self.updateItems(nil) + } + } + /// Starts observing the changes in the database. /// - Throws: An error if the fetch fails. func startObserving() throws { diff --git a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift index 71fce7b719a..6dc81aa1a31 100644 --- a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift @@ -360,6 +360,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 51b78294990fc39b1da51c4086a536a72642965a Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 23 Apr 2026 16:12:05 +0300 Subject: [PATCH 21/22] 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 6c25cf4aafe..d8210cdd5f7 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 8993252e385..d7e4d466af3 100644 --- a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift +++ b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift @@ -35,7 +35,7 @@ extension ChatClient { /// - Note: For an async-await alternative of the `ChatChannelListController`, please check ``ChannelList`` in the async-await supported [state layer](https://getstream.io/chat/docs/sdk/ios/client/state-layer/state-layer-overview/). public class ChatChannelListController: DataController, DelegateCallable, DataStoreProvider { /// The query specifying and filtering the list of channels. - public let query: ChannelListQuery + public private(set) var query: ChannelListQuery /// The `ChatClient` instance this controller belongs to. public let client: ChatClient @@ -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() { @@ -218,19 +213,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: ((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 2daade7c85b..8bf0391bcec 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift @@ -443,7 +443,7 @@ extension NSManagedObjectContext { } func delete(query: ChannelListQuery) { - guard let dto = channelListQuery(filterHash: query.filter.filterHash) else { return } + guard let dto = channelListQuery(query) else { return } delete(dto) } @@ -476,7 +476,7 @@ extension ChannelDTO { request.sortDescriptors = sortDescriptors.isEmpty ? [ChannelListSortingKey.defaultSortDescriptor] : sortDescriptors - let matchingQuery = NSPredicate(format: "ANY queries.filterHash == %@", query.filter.filterHash) + let matchingQuery = NSPredicate(format: "ANY queries.filterHash == %@", query.queryHash) let notDeleted = NSPredicate(format: "deletedAt == nil") var subpredicates: [NSPredicate] = [ diff --git a/Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift index 0b977f305a2..406b6952d80 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift @@ -35,21 +35,21 @@ class ChannelListQueryDTO: NSManagedObject { } extension NSManagedObjectContext { - func channelListQuery(filterHash: String) -> ChannelListQueryDTO? { - ChannelListQueryDTO.load(filterHash: filterHash, context: self) + func channelListQuery(_ query: ChannelListQuery) -> ChannelListQueryDTO? { + ChannelListQueryDTO.load(filterHash: query.queryHash, context: self) } func saveQuery(query: ChannelListQuery) -> ChannelListQueryDTO { - if let existingDTO = channelListQuery(filterHash: query.filter.filterHash) { + if let existingDTO = channelListQuery(query) { return existingDTO } let request = ChannelListQueryDTO.fetchRequest( keyPath: #keyPath(ChannelListQueryDTO.filterHash), - equalTo: query.filter.filterHash + equalTo: query.queryHash ) let newDTO = NSEntityDescription.insertNewObject(into: self, for: request) - newDTO.filterHash = query.filter.filterHash + newDTO.filterHash = query.queryHash let jsonData: Data do { diff --git a/Sources/StreamChat/Database/DatabaseSession.swift b/Sources/StreamChat/Database/DatabaseSession.swift index ea0aa0075cd..819f2bbf527 100644 --- a/Sources/StreamChat/Database/DatabaseSession.swift +++ b/Sources/StreamChat/Database/DatabaseSession.swift @@ -353,9 +353,10 @@ protocol ChannelDatabaseSession { cache: PreWarmedCache? ) throws -> ChannelDTO - /// Loads channel list query with the given filter hash from the database. - /// - Parameter filterHash: The filter hash. - func channelListQuery(filterHash: String) -> ChannelListQueryDTO? + /// Loads the `ChannelListQueryDTO` corresponding to the given `ChannelListQuery`. + /// Lookup uses `query.queryHash` — `groupKey` when set, otherwise `filter.filterHash`. + /// - Parameter query: The channel list query. + func channelListQuery(_ query: ChannelListQuery) -> ChannelListQueryDTO? /// Loads all channel list queries from the database. /// - Returns: The array of channel list queries. 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 550ac74e1c5..52683a564b7 100644 --- a/Sources/StreamChat/Query/ChannelListQuery.swift +++ b/Sources/StreamChat/Query/ChannelListQuery.swift @@ -71,6 +71,15 @@ public struct ChannelListQuery: Encodable, LocalConvertibleSortingQuery { try options.encode(to: encoder) try pagination.encode(to: encoder) } + + var groupKey: String? + + /// The stable identity used for locating / linking the corresponding `ChannelListQueryDTO`. + /// Uses `groupKey` when set (stable across date-bearing filters from the grouped endpoint); + /// otherwise falls back to `filter.filterHash`. + var queryHash: String { + groupKey ?? filter.filterHash + } } extension ChannelListQuery: CustomDebugStringConvertible { diff --git a/Sources/StreamChat/Repositories/SyncOperations.swift b/Sources/StreamChat/Repositories/SyncOperations.swift index 80984c97f95..86e85ea4fe9 100644 --- a/Sources/StreamChat/Repositories/SyncOperations.swift +++ b/Sources/StreamChat/Repositories/SyncOperations.swift @@ -124,14 +124,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 5f7c45293da..c3d7ba66160 100644 --- a/Sources/StreamChat/Repositories/SyncRepository.swift +++ b/Sources/StreamChat/Repositories/SyncRepository.swift @@ -194,8 +194,8 @@ class SyncRepository { // 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 0a5a9b60240..9ddef025b40 100644 --- a/Sources/StreamChat/Workers/ChannelListUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelListUpdater.swift @@ -23,8 +23,7 @@ class ChannelListUpdater: Worker { var initialActions: ((DatabaseSession) -> Void)? if isInitialFetch { initialActions = { session in - let filterHash = channelListQuery.filter.filterHash - guard let queryDTO = session.channelListQuery(filterHash: filterHash) else { return } + guard let queryDTO = session.channelListQuery(channelListQuery) else { return } queryDTO.channels.removeAll() } } @@ -42,7 +41,7 @@ class ChannelListUpdater: Worker { } func prefill( - channels: [ChatChannel], + group: GroupedChannelsGroup, for query: ChannelListQuery, completion: ((Result<[ChatChannel], Error>) -> Void)? = nil ) { @@ -51,7 +50,7 @@ class ChannelListUpdater: Worker { 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 { 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 { 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 91de1612b88..5a615c6263f 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift @@ -36,6 +36,16 @@ class ChatChannelListController_Mock: ChatChannelListController, Spy { record() refreshLoadedChannelsResult.map(completion) } + + @Atomic var prefill_groups: [GroupedChannelsGroup] = [] + override func prefill( + group: GroupedChannelsGroup, + completion: ((Error?) -> Void)? = nil + ) { + record() + _prefill_groups.mutate { $0.append(group) } + completion?(nil) + } } extension ChatChannelListController_Mock { diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift index aa34ca61cde..687eb6f0f6e 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift @@ -388,8 +388,8 @@ class DatabaseSession_Mock: DatabaseSession { underlyingSession.saveQuery(query: query) } - func channelListQuery(filterHash: String) -> ChannelListQueryDTO? { - underlyingSession.channelListQuery(filterHash: filterHash) + func channelListQuery(_ query: ChannelListQuery) -> ChannelListQueryDTO? { + underlyingSession.channelListQuery(query) } func loadAllChannelListQueries() -> [ChannelListQueryDTO] { diff --git a/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift b/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift index bf821a3550a..8ed12cbb37b 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 { } 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 28ccbcaf89d..7489eac33b6 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -329,11 +329,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 6dc81aa1a31..0eb23bf3fba 100644 --- a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift @@ -251,7 +251,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() } @@ -281,7 +281,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() } @@ -309,7 +309,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() } @@ -338,7 +338,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() } @@ -371,7 +371,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() } @@ -394,7 +394,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() } @@ -436,7 +436,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 253e379dbc3..daed82e4e1c 100644 --- a/Tests/StreamChatTests/Query/Sorting/ListDatabaseObserver+Sorting_Tests.swift +++ b/Tests/StreamChatTests/Query/Sorting/ListDatabaseObserver+Sorting_Tests.swift @@ -354,7 +354,7 @@ final class ListDatabaseObserver_Sorting_Tests: XCTestCase { ) } - guard let queryDTO = session.channelListQuery(filterHash: self.query.filter.filterHash) else { + guard let queryDTO = session.channelListQuery(self.query) else { return } for channel in channels { diff --git a/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift b/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift index 289a4915dbb..da16554d605 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 4ab687ff847..09a1b68e9eb 100644 --- a/Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift +++ b/Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift @@ -112,7 +112,7 @@ final class ChannelListUpdater_Tests: XCTestCase { // Assert the data is stored in the DB var queryDTO: ChannelListQueryDTO? { - database.viewContext.channelListQuery(filterHash: query.filter.filterHash) + database.viewContext.channelListQuery(query) } AssertAsync { Assert.willBeTrue(queryDTO != nil) @@ -133,9 +133,7 @@ final class ChannelListUpdater_Tests: XCTestCase { } var channelsFromQuery: [ChatChannel] { - database.viewContext.channelListQuery( - filterHash: query.filter.filterHash - )?.channels.compactMap { try? $0.asModel() } ?? [] + database.viewContext.channelListQuery(query)?.channels.compactMap { try? $0.asModel() } ?? [] } XCTAssertEqual(channelsFromQuery.count, 3) @@ -169,9 +167,7 @@ final class ChannelListUpdater_Tests: XCTestCase { } var channelsFromQuery: [ChatChannel] { - database.viewContext.channelListQuery( - filterHash: query.filter.filterHash - )?.channels.compactMap { try? $0.asModel() } ?? [] + database.viewContext.channelListQuery(query)?.channels.compactMap { try? $0.asModel() } ?? [] } XCTAssertEqual(channelsFromQuery.count, 3) @@ -205,9 +201,7 @@ final class ChannelListUpdater_Tests: XCTestCase { } var channelsFromQuery: [ChatChannel] { - database.viewContext.channelListQuery( - filterHash: query.filter.filterHash - )?.channels.compactMap { try? $0.asModel() } ?? [] + database.viewContext.channelListQuery(query)?.channels.compactMap { try? $0.asModel() } ?? [] } XCTAssertEqual(channelsFromQuery.count, 3) @@ -409,9 +403,7 @@ final class ChannelListUpdater_Tests: XCTestCase { waitForExpectations(timeout: defaultTimeout) var channelsInQuery: [ChatChannel] { - database.viewContext.channelListQuery( - filterHash: query.filter.filterHash - )?.channels.compactMap { try? $0.asModel() } ?? [] + database.viewContext.channelListQuery(query)?.channels.compactMap { try? $0.asModel() } ?? [] } XCTAssertTrue(channelsInQuery.contains(where: { $0.cid == channel.cid })) @@ -431,9 +423,7 @@ final class ChannelListUpdater_Tests: XCTestCase { } var channelsInQuery: [ChatChannel] { - database.viewContext.channelListQuery( - filterHash: query.filter.filterHash - )?.channels.compactMap { try? $0.asModel() } ?? [] + database.viewContext.channelListQuery(query)?.channels.compactMap { try? $0.asModel() } ?? [] } XCTAssertTrue(channelsInQuery.contains(where: { $0.cid == channel.cid })) From f7d1f026a6131cd526076a4446dfcc349eea4cec Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 23 Apr 2026 16:30:25 +0300 Subject: [PATCH 22/22] 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