diff --git a/.github/workflows/smoke-checks.yml b/.github/workflows/smoke-checks.yml
index a90c3f7b33f..b0b10b51ec1 100644
--- a/.github/workflows/smoke-checks.yml
+++ b/.github/workflows/smoke-checks.yml
@@ -71,6 +71,7 @@ jobs:
- run: bundle exec fastlane rubocop
- run: bundle exec fastlane run_swift_format strict:true
- run: bundle exec fastlane validate_public_interface
+ - run: bundle exec fastlane validate_generated_code
build-old-xcode:
name: Build SDKs (Old Xcode)
diff --git a/.swiftlint.yml b/.swiftlint.yml
index 118f8ddcb66..8b01c72f9b7 100644
--- a/.swiftlint.yml
+++ b/.swiftlint.yml
@@ -1,5 +1,6 @@
excluded:
- Scripts
+ - Sources/StreamChat/Generated
- Sources/StreamChatCommonUI/Generated
- Sources/StreamChatUI/StreamSwiftyGif
- Sources/StreamChatUI/StreamNuke
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6a50acc221e..07a0dcf6344 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### 🔄 Changed
+# [5.5.0](https://github.com/GetStream/stream-chat-swift/releases/tag/5.5.0)
+_June 03, 2026_
+
+## StreamChat
+### ✅ Added
+- Add `ChannelListQuery(predefinedFilter:filterValues:sortValues:)` for creating channel list queries with predefined filters [#4113](https://github.com/GetStream/stream-chat-swift/pull/4113)
+- Add `ChatClient.queryGroupedChannels(groups:limit:presence:watch:)` to fetch grouped channels with per-group unread counts [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076)
+- Add `ChatClient.makeChannelList(with:)` overload for observing a single grouped channels group in the state layer [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076)
+- Add `unreadChannelCountsByGroup` to `CurrentChatUser`, observable for changes via `ConnectedUser` [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076)
+
# [5.4.1](https://github.com/GetStream/stream-chat-swift/releases/tag/5.4.1)
_June 01, 2026_
@@ -12,8 +22,6 @@ _June 01, 2026_
### 🐞 Fixed
- Respect `ChatClientConfig.shouldShowShadowedMessages` in `LivestreamChat` and `LivestreamChannelController` [#4118](https://github.com/GetStream/stream-chat-swift/pull/4118)
-### 🔄 Changed
-
# [5.4.0](https://github.com/GetStream/stream-chat-swift/releases/tag/5.4.0)
_May 28, 2026_
diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListVC.swift b/DemoApp/StreamChat/Components/DemoChatChannelListVC.swift
index 58bac6f0d37..275e0086b0a 100644
--- a/DemoApp/StreamChat/Components/DemoChatChannelListVC.swift
+++ b/DemoApp/StreamChat/Components/DemoChatChannelListVC.swift
@@ -110,6 +110,34 @@ final class DemoChatChannelListVC: ChatChannelListVC {
lazy var premiumTaggedChannelsQuery: ChannelListQuery = .init(filter: .in(.filterTags, values: ["premium"]))
+ lazy var predefinedMessagingChannelsQuery: ChannelListQuery = .init(
+ predefinedFilter: "user_per_channel_type_channels",
+ filterValues: ["channel_type": .string(ChannelType.messaging.rawValue), "user_id": .string(currentUserId)],
+ sortValues: nil
+ )
+
+ lazy var predefinedArchivedChannelsQuery: ChannelListQuery = .init(
+ predefinedFilter: "user_per_channel_type_archived_hidden",
+ filterValues: [
+ "channel_type": .string(ChannelType.messaging.rawValue),
+ "hidden": .bool(false),
+ "user_id": .string(currentUserId),
+ "archived": .bool(true)
+ ],
+ sortValues: nil
+ )
+
+ lazy var predefinedHiddenChannelsQuery: ChannelListQuery = .init(
+ predefinedFilter: "user_per_channel_type_archived_hidden",
+ filterValues: [
+ "channel_type": .string(ChannelType.messaging.rawValue),
+ "hidden": .bool(true),
+ "user_id": .string(currentUserId),
+ "archived": .bool(false)
+ ],
+ sortValues: nil
+ )
+
lazy var livestreamChannelsQuery: ChannelListQuery = .init(filter: .equal(.type, to: .livestream))
var demoRouter: DemoChatChannelListRouter? {
@@ -270,6 +298,33 @@ final class DemoChatChannelListVC: ChatChannelListVC {
}
)
+ let predefinedMessagingChannelsAction = UIAlertAction(
+ title: "Messaging (Predefined)",
+ style: .default,
+ handler: { [weak self] _ in
+ self?.title = "Messaging (Predefined)"
+ self?.setPredefinedMessagingChannelsQuery()
+ }
+ )
+
+ let predefinedArchivedChannelsAction = UIAlertAction(
+ title: "Archived (Predefined)",
+ style: .default,
+ handler: { [weak self] _ in
+ self?.title = "Archived (Predefined)"
+ self?.setPredefinedArchivedChannelsQuery()
+ }
+ )
+
+ let predefinedHiddenChannelsAction = UIAlertAction(
+ title: "Hidden (Predefined)",
+ style: .default,
+ handler: { [weak self] _ in
+ self?.title = "Hidden (Predefined)"
+ self?.setPredefinedHiddenChannelsQuery()
+ }
+ )
+
let livestreamChannelsAction = UIAlertAction(
title: "Livestream Channels",
style: .default
@@ -294,6 +349,9 @@ final class DemoChatChannelListVC: ChatChannelListVC {
equalMembersAction,
channelRoleChannelsAction,
taggedChannelsAction,
+ predefinedMessagingChannelsAction,
+ predefinedArchivedChannelsAction,
+ predefinedHiddenChannelsAction,
livestreamChannelsAction
].sorted(by: { $0.title ?? "" < $1.title ?? "" }),
preferredStyle: .actionSheet,
@@ -355,6 +413,18 @@ final class DemoChatChannelListVC: ChatChannelListVC {
replaceQuery(premiumTaggedChannelsQuery)
}
+ func setPredefinedMessagingChannelsQuery() {
+ replaceQuery(predefinedMessagingChannelsQuery)
+ }
+
+ func setPredefinedArchivedChannelsQuery() {
+ replaceQuery(predefinedArchivedChannelsQuery)
+ }
+
+ func setPredefinedHiddenChannelsQuery() {
+ replaceQuery(predefinedHiddenChannelsQuery)
+ }
+
func setLivestreamChannelsQuery() {
replaceQuery(livestreamChannelsQuery)
}
diff --git a/Githubfile b/Githubfile
index 144dffafb0c..32b54a161b6 100644
--- a/Githubfile
+++ b/Githubfile
@@ -10,3 +10,4 @@ export INTERFACE_ANALYZER_VERSION='1.0.7'
export SWIFT_LINT_VERSION='0.59.1'
export SWIFT_FORMAT_VERSION='0.58.2'
export SWIFT_GEN_VERSION='6.5.1'
+export SOURCERY_VERSION='2.3.0'
diff --git a/Makefile b/Makefile
index 58a9575b697..9b33f6e66b1 100644
--- a/Makefile
+++ b/Makefile
@@ -3,6 +3,9 @@ MAKEFLAGS += --silent
bootstrap:
./Scripts/bootstrap.sh
+generate:
+ sourcery --config Sources/StreamChat/.sourcery.yml
+
update_dependencies:
echo "👉 Updating Nuke"
make update_nuke version=10.3.3
diff --git a/Package.swift b/Package.swift
index a2698479d2f..36a4c635ac7 100644
--- a/Package.swift
+++ b/Package.swift
@@ -41,7 +41,7 @@ let package = Package(
dependencies: [
.product(name: "StreamCore", package: "stream-core-swift")
],
- exclude: ["Info.plist"],
+ exclude: ["Info.plist", "Generated/PredefinedFilter.stencil"],
resources: [.copy("Database/StreamChatModel.xcdatamodeld")]
),
.target(
diff --git a/README.md b/README.md
index 0e1a4109094..0764b41e910 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
-
+
diff --git a/Scripts/bootstrap.sh b/Scripts/bootstrap.sh
index 122115dd34a..30481fd1473 100755
--- a/Scripts/bootstrap.sh
+++ b/Scripts/bootstrap.sh
@@ -57,6 +57,19 @@ if [ "${SKIP_SWIFT_BOOTSTRAP:-}" != true ]; then
sudo sudo rm -f "$BIN_PATH"
sudo sudo ln -s "$INSTALL_DIR/bin/swiftgen" "$BIN_PATH"
swiftgen --version
+
+ puts "Install Sourcery v${SOURCERY_VERSION}"
+ DOWNLOAD_URL="https://github.com/krzysztofzablocki/Sourcery/releases/download/${SOURCERY_VERSION}/sourcery-${SOURCERY_VERSION}.zip"
+ DOWNLOAD_PATH="/tmp/sourcery-${SOURCERY_VERSION}.zip"
+ INSTALL_DIR="/usr/local/lib/sourcery"
+ BIN_PATH="/usr/local/bin/sourcery"
+ wget "$DOWNLOAD_URL" -O "$DOWNLOAD_PATH"
+ sudo rm -rf "$INSTALL_DIR"
+ sudo mkdir -p "$INSTALL_DIR"
+ sudo unzip -o "$DOWNLOAD_PATH" -d "$INSTALL_DIR"
+ sudo rm -f "$BIN_PATH"
+ sudo ln -s "$INSTALL_DIR/bin/sourcery" "$BIN_PATH"
+ sourcery --version
fi
if [[ ${INSTALL_SONAR-default} == true ]]; then
diff --git a/Sources/StreamChat/.sourcery.yml b/Sources/StreamChat/.sourcery.yml
new file mode 100644
index 00000000000..65c79933e4e
--- /dev/null
+++ b/Sources/StreamChat/.sourcery.yml
@@ -0,0 +1,7 @@
+sources:
+ - ./Query/ChannelListQuery.swift
+ - ./Query/Sorting/ChannelListSortingKey.swift
+templates:
+ - ./Generated/PredefinedFilter.stencil
+output:
+ ./Generated/PredefinedFilter+Generated.swift
diff --git a/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift
index 18d6b7a7434..002a6a3426f 100644
--- a/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift
+++ b/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift
@@ -15,6 +15,18 @@ extension Endpoint {
)
}
+ static func groupedChannels(
+ request: GroupedQueryChannelsRequestBody
+ ) -> Endpoint {
+ .init(
+ path: .groupedChannels,
+ method: .post,
+ queryItems: nil,
+ requiresConnectionId: request.watch || request.presence,
+ body: request
+ )
+ }
+
static func createChannel(query: ChannelQuery) -> Endpoint {
createOrUpdateChannel(path: .createChannel(query.apiPath), query: query)
}
diff --git a/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift b/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift
index 1644c05eebc..63da32625c4 100644
--- a/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift
+++ b/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift
@@ -9,7 +9,7 @@ extension EndpointPath {
switch self {
case .sendMessage, .editMessage, .deleteMessage, .pinMessage, .unpinMessage, .addReaction, .deleteReaction, .draftMessage:
return true
- case .createChannel, .connect, .sync, .users, .guest, .members, .partialMemberUpdate, .search, .devices, .channels, .updateChannel,
+ case .createChannel, .connect, .sync, .users, .guest, .members, .partialMemberUpdate, .search, .devices, .channels, .groupedChannels, .updateChannel,
.deleteChannel, .channelUpdate, .muteChannel, .showChannel, .truncateChannel, .markChannelRead, .markChannelUnread,
.markAllChannelsRead, .markChannelsDelivered, .channelEvent, .stopWatchingChannel, .pinnedMessages, .uploadChannelAttachment, .message,
.replies, .reactions, .messageAction, .banMember, .flagUser, .flagMessage, .muteUser, .translateMessage,
diff --git a/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift b/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift
index 5d960b2ac91..7bed7823687 100644
--- a/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift
+++ b/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift
@@ -24,6 +24,7 @@ enum EndpointPath: Codable {
case markThreadUnread(cid: ChannelId)
case channels
+ case groupedChannels
case createChannel(String)
case updateChannel(String)
case deleteChannel(String)
@@ -116,6 +117,7 @@ enum EndpointPath: Codable {
case .liveLocations: return "users/live_locations"
case .channels: return "channels"
+ case .groupedChannels: return "channels/grouped"
case let .createChannel(queryString): return "channels/\(queryString)/query"
case let .updateChannel(queryString): return "channels/\(queryString)/query"
case let .deleteChannel(payloadPath): return "channels/\(payloadPath)"
diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift
index 1eeee0927ae..61d29227d09 100644
--- a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift
+++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift
@@ -7,24 +7,128 @@ import Foundation
struct ChannelListPayload {
/// A list of channels response (see `ChannelQuery`).
let channels: [ChannelPayload]
+
+ /// Server-resolved predefined filter, present only when the query was made with a predefined filter.
+ let predefinedFilter: PredefinedFilterPayload?
+
+ init(channels: [ChannelPayload], predefinedFilter: PredefinedFilterPayload? = nil) {
+ self.channels = channels
+ self.predefinedFilter = predefinedFilter
+ }
}
extension ChannelListPayload: Decodable {
enum CodingKeys: String, CodingKey {
case channels
+ case predefinedFilter = "predefined_filter"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let channels = try container
.decodeArrayIgnoringFailures([ChannelPayload].self, forKey: .channels)
+ let predefinedFilter = try container
+ .decodeIfPresent(PredefinedFilterPayload.self, forKey: .predefinedFilter)
self.init(
- channels: channels
+ channels: channels,
+ predefinedFilter: predefinedFilter
)
}
}
+final class PredefinedFilterPayload: Decodable, Sendable {
+ let name: String
+ let filter: [String: RawJSON]
+ let sort: [[String: RawJSON]]
+
+ init(name: String, filter: [String: RawJSON], sort: [[String: RawJSON]]) {
+ self.name = name
+ self.filter = filter
+ self.sort = sort
+ }
+}
+
+final class GroupedQueryChannelsRequestBody: Encodable, Sendable {
+ let limit: Int?
+ let groups: [String: GroupedQueryChannelsRequestGroup]?
+ let watch: Bool
+ let presence: Bool
+
+ init(
+ limit: Int?,
+ groups: [String: GroupedQueryChannelsRequestGroup]?,
+ watch: Bool,
+ presence: Bool
+ ) {
+ self.limit = limit
+ self.groups = groups
+ self.watch = watch
+ self.presence = presence
+ }
+}
+
+final class GroupedQueryChannelsRequestGroup: Encodable, Sendable {
+ let limit: Int?
+ let next: String?
+
+ init(limit: Int?, next: String?) {
+ self.limit = limit
+ self.next = next
+ }
+}
+
+final class GroupedQueryChannelsPayload: Decodable, Sendable {
+ let groups: [String: GroupedQueryChannelsGroupPayload]
+
+ init(groups: [String: GroupedQueryChannelsGroupPayload]) {
+ self.groups = groups
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case groups
+ }
+
+ required init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ groups = try container.decode([String: GroupedQueryChannelsGroupPayload].self, forKey: .groups)
+ }
+}
+
+final class GroupedQueryChannelsGroupPayload: Decodable, Sendable {
+ let channels: [ChannelPayload]
+ let unreadChannels: Int
+ let next: String?
+ let prev: String?
+
+ init(
+ channels: [ChannelPayload],
+ unreadChannels: Int,
+ next: String? = nil,
+ prev: String? = nil
+ ) {
+ self.channels = channels
+ self.unreadChannels = unreadChannels
+ self.next = next
+ self.prev = prev
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case channels
+ case unreadChannels = "unread_channels"
+ case next
+ case prev
+ }
+
+ required init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ channels = try container.decodeArrayIgnoringFailures([ChannelPayload].self, forKey: .channels)
+ unreadChannels = try container.decodeIfPresent(Int.self, forKey: .unreadChannels) ?? 0
+ next = try container.decodeIfPresent(String.self, forKey: .next)
+ prev = try container.decodeIfPresent(String.self, forKey: .prev)
+ }
+}
+
struct ChannelPayload {
let channel: ChannelDetailPayload
diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift
index 108ebf2a041..e4ee890d639 100644
--- a/Sources/StreamChat/ChatClient.swift
+++ b/Sources/StreamChat/ChatClient.swift
@@ -619,7 +619,7 @@ public class ChatClient: @unchecked Sendable {
eventNotificationCenter.subscribe(handler: handler)
}
- // MARK: -
+ // MARK: - App Settings
/// Fetches the app settings and updates the ``ChatClient/appSettings``.
/// - Parameter completion: The completion block once the app settings has finished fetching.
@@ -649,6 +649,39 @@ public class ChatClient: @unchecked Sendable {
}
}
+ // MARK: - Grouped Channels
+
+ /// Fetches the first page of channels for the requested groups in a single request.
+ ///
+ /// To observe and paginate a group's channels, create a ``ChannelList`` for its
+ /// ``ChannelGroup/groupKey`` via ``ChatClient/makeChannelList(with:)-(String)`` and read
+ /// ``ChannelListState/channels``.
+ ///
+ /// - Parameters:
+ /// - groups: The group keys to fetch.
+ /// - limit: The number of channels to return per group. `nil` uses the backend default.
+ /// - presence: When `true`, includes presence info and streams presence updates over the WebSocket.
+ /// - watch: When `true`, subscribes to WebSocket events for the returned channels.
+ ///
+ /// - Returns: The fetched ``ChannelGroup`` values.
+ /// - Throws: An error while communicating with the Stream API.
+ @discardableResult public func queryGroupedChannels(
+ groups: [String],
+ limit: Int? = nil,
+ presence: Bool = false,
+ watch: Bool = true
+ ) async throws -> [ChannelGroup] {
+ let groupRequests: [String: GroupedQueryChannelsRequestGroup]? = groups.isEmpty ? nil : groups.reduce(into: [:]) { result, key in
+ result[key] = GroupedQueryChannelsRequestGroup(limit: limit, next: nil)
+ }
+ return try await channelListUpdater.queryGroupedChannels(
+ groups: groupRequests,
+ limit: groupRequests == nil ? limit : nil,
+ watch: watch,
+ presence: presence
+ )
+ }
+
// MARK: - Upload attachments
/// Uploads an attachment to the specified CDN.
diff --git a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift
index b7b0f206eef..7349929c24c 100644
--- a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift
+++ b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift
@@ -35,7 +35,7 @@ extension ChatClient {
/// - Note: For an async-await alternative of the `ChatChannelListController`, please check ``ChannelList`` in the async-await supported [state layer](https://getstream.io/chat/docs/sdk/ios/client/state-layer/state-layer-overview/).
public class ChatChannelListController: DataController, DelegateCallable, DataStoreProvider, @unchecked Sendable {
/// The query specifying and filtering the list of channels.
- public let query: ChannelListQuery
+ public internal(set) var query: ChannelListQuery
/// The `ChatClient` instance this controller belongs to.
public let client: ChatClient
@@ -82,8 +82,15 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt
}
private(set) lazy var channelListObserver: BackgroundListDatabaseObserver = {
+ if let updated = worker.loadPredefinedFilter(for: query) {
+ query = updated
+ }
+ return makeChannelListObserver()
+ }()
+
+ private func makeChannelListObserver() -> BackgroundListDatabaseObserver {
let request = ChannelDTO.channelListFetchRequest(query: self.query, chatClientConfig: client.config)
- let observer = self.environment.createChannelListDatabaseObserver(
+ let observer = environment.createChannelListDatabaseObserver(
client.databaseContainer,
request,
{ try $0.asModel() },
@@ -101,7 +108,7 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt
}
}
return observer
- }()
+ }
var _basePublishers: Any?
/// An internal backing object for all publicly available Combine publishers. We use it to simplify the way we expose
@@ -146,11 +153,8 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt
startChannelListObserverIfNeeded()
channelListLinker.start(with: client.eventNotificationCenter)
client.syncRepository.startTrackingChannelListController(self)
- updateChannelList { [weak self] error in
- guard let completion else { return }
- self?.callback {
- completion(error)
- }
+ updateChannelList { [weak self] result in
+ self?.callback { completion?(result.error) }
}
}
@@ -179,9 +183,9 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt
updatedQuery.pagination = Pagination(pageSize: limit, offset: channels.count)
worker.update(channelListQuery: updatedQuery) { result in
switch result {
- case let .success(channels):
- self.markChannelsAsDeliveredIfNeeded(channels: channels)
- self.hasLoadedAllPreviousChannels = channels.count < limit
+ case let .success(updateResult):
+ self.markChannelsAsDeliveredIfNeeded(channels: updateResult.channels)
+ self.hasLoadedAllPreviousChannels = updateResult.channels.count < limit
self.callback { completion?(nil) }
case let .failure(error):
self.callback { completion?(error) }
@@ -199,24 +203,30 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt
// MARK: - Helpers
private func updateChannelList(
- _ completion: (@MainActor (_ error: Error?) -> Void)? = nil
+ _ completion: (@MainActor (Result) -> Void)? = nil
) {
let limit = query.pagination.pageSize
worker.update(
channelListQuery: query
) { [weak self] result in
switch result {
- case let .success(channels):
+ case let .success(updateResult):
self?.state = .remoteDataFetched
- self?.hasLoadedAllPreviousChannels = channels.count < limit
+ self?.hasLoadedAllPreviousChannels = updateResult.channels.count < limit
// Mark channels as delivered if synchronization was successful
- self?.markChannelsAsDeliveredIfNeeded(channels: channels)
+ self?.markChannelsAsDeliveredIfNeeded(channels: updateResult.channels)
+
+ // Predefined filters can update local query representation (query gets backend defined filter and sort which must be set to FRC)
+ if let updatedQuery = updateResult.updatedQuery {
+ self?.query = updatedQuery
+ self?.updateChannelListObserver()
+ }
- self?.callback { completion?(nil) }
+ self?.callback { completion?(.success(updateResult)) }
case let .failure(error):
self?.state = .remoteDataFetchFailed(ClientError(with: error))
- self?.callback { completion?(error) }
+ self?.callback { completion?(.failure(error)) }
}
}
}
@@ -264,6 +274,16 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt
log.error("Failed to perform fetch request with error: \(error). This is an internal error.")
}
}
+
+ private func updateChannelListObserver() {
+ channelListObserver = makeChannelListObserver()
+ do {
+ try channelListObserver.startObserving()
+ } catch {
+ state = .localDataFetchFailed(ClientError(with: error))
+ log.error("Failed to update the channel list observer: \(error)")
+ }
+ }
}
extension ChatChannelListController {
diff --git a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift
index 6b8dbb4c2a1..8c743be886a 100644
--- a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift
+++ b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift
@@ -234,7 +234,7 @@ extension NSManagedObjectContext {
// the query won't be saved, which will cause any future
// channels to not become linked to this query
if let query = query {
- _ = saveQuery(query: query)
+ _ = saveQuery(query: query, predefinedFilter: payload.predefinedFilter)
}
return payload.channels.compactMapLoggingError { channelPayload in
@@ -444,7 +444,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)
}
@@ -477,7 +477,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] = [
@@ -497,8 +497,10 @@ extension ChannelDTO {
}
request.predicate = NSCompoundPredicate(type: .and, subpredicates: subpredicates)
- request.fetchLimit = query.pagination.pageSize
- request.fetchBatchSize = query.pagination.pageSize
+ // Backend driven page size is enabled with Int.backendDefaultPageSize (-1). Keep CoreData fetching efficient and use default channels page size.
+ let limit = query.pagination.pageSize > 0 ? query.pagination.pageSize : .channelsPageSize
+ request.fetchLimit = limit
+ request.fetchBatchSize = limit
return request
}
diff --git a/Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift
index 3163de1868b..210e3b9180a 100644
--- a/Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift
+++ b/Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift
@@ -12,14 +12,28 @@ class ChannelListQueryDTO: NSManagedObject {
/// Serialized `Filter` JSON which can be used in cases the query needs to be repeated, i.e. for newly created channels.
@NSManaged var filterJSONData: Data
+ /// Serialized sort JSON returned by the server for predefined-filter queries.
+ @NSManaged var sortJSONData: Data?
+
+ /// Next-page cursor for grouped queries; `nil` when there are no more pages.
+ @NSManaged var next: String?
+
+ /// `watch` flag from the original grouped-channels request, reused on pagination
+ /// and sync refetches. Grouped queries only.
+ @NSManaged var watch: Bool
+
+ /// `presence` flag from the original grouped-channels request, reused on pagination
+ /// and sync refetches. Grouped queries only.
+ @NSManaged var presence: Bool
+
// MARK: - Relationships
@NSManaged var channels: Set
- static func load(filterHash: String, context: NSManagedObjectContext) -> ChannelListQueryDTO? {
+ static func load(query: ChannelListQuery, context: NSManagedObjectContext) -> ChannelListQueryDTO? {
load(
keyPath: #keyPath(ChannelListQueryDTO.filterHash),
- equalTo: filterHash,
+ equalTo: query.queryHash,
context: context
).first as? Self
}
@@ -35,33 +49,77 @@ class ChannelListQueryDTO: NSManagedObject {
}
extension NSManagedObjectContext {
- func channelListQuery(filterHash: String) -> ChannelListQueryDTO? {
- ChannelListQueryDTO.load(filterHash: filterHash, context: self)
+ func channelListQuery(_ query: ChannelListQuery) -> ChannelListQueryDTO? {
+ ChannelListQueryDTO.load(query: query, context: self)
}
- func saveQuery(query: ChannelListQuery) -> ChannelListQueryDTO {
- if let existingDTO = channelListQuery(filterHash: query.filter.filterHash) {
- return existingDTO
+ /// Returns the query with persisted predefined filter/sort applied.
+ /// `nil` when the input has no `predefinedFilter` or no cached DTO exists.
+ /// Callers compare `filter`/`sort` against the input (see `ChannelListQuery.isFilterEqual(to:)`)
+ /// to detect whether the cached resolution actually differs from the current query.
+ func loadPredefinedFilter(for query: ChannelListQuery) -> ChannelListQuery? {
+ guard let predefinedFilter = query.predefinedFilter, !predefinedFilter.isEmpty,
+ let dto = channelListQuery(query) else {
+ return nil
}
- let request = ChannelListQueryDTO.fetchRequest(
- keyPath: #keyPath(ChannelListQueryDTO.filterHash),
- equalTo: query.filter.filterHash
- )
- let newDTO = NSEntityDescription.insertNewObject(into: self, for: request)
- newDTO.filterHash = query.filter.filterHash
-
- let jsonData: Data
+ var updated = query
do {
- jsonData = try JSONEncoder.default.encode(query.filter)
+ if let filter = try Filter.predefinedFilter(fromJSONData: dto.filterJSONData) {
+ updated.filter = filter
+ }
} catch {
- log.error("Failed encoding query Filter data with error: \(error).")
- jsonData = Data()
+ log.error("Failed decoding predefined filter from persisted data with error: \(error).")
}
+ if let sortJSONData = dto.sortJSONData {
+ do {
+ if let sort = try [Sorting].predefinedFilterSort(fromJSONData: sortJSONData) {
+ updated.sort = sort
+ }
+ } catch {
+ log.error("Failed decoding predefined sort from persisted data with error: \(error).")
+ }
+ }
+ return updated
+ }
- newDTO.filterJSONData = jsonData
+ func saveQuery(query: ChannelListQuery, predefinedFilter: PredefinedFilterPayload? = nil) -> ChannelListQueryDTO {
+ let dto: ChannelListQueryDTO
+ if let existingDTO = channelListQuery(query) {
+ dto = existingDTO
+ } else {
+ let request = ChannelListQueryDTO.fetchRequest(
+ keyPath: #keyPath(ChannelListQueryDTO.filterHash),
+ equalTo: query.queryHash
+ )
+ let newDTO = NSEntityDescription.insertNewObject(into: self, for: request)
+ newDTO.filterHash = query.queryHash
+
+ let jsonData: Data
+ do {
+ jsonData = try JSONEncoder.default.encode(query.filter)
+ } catch {
+ log.error("Failed encoding query Filter data with error: \(error).")
+ jsonData = Data()
+ }
+ newDTO.filterJSONData = jsonData
+ dto = newDTO
+ }
+
+ if let predefinedFilter {
+ do {
+ dto.filterJSONData = try JSONEncoder.default.encode(predefinedFilter.filter)
+ } catch {
+ log.error("Failed encoding predefined filter from response with error: \(error).")
+ }
+ do {
+ dto.sortJSONData = try JSONEncoder.default.encode(predefinedFilter.sort)
+ } catch {
+ log.error("Failed encoding predefined sort from response with error: \(error).")
+ }
+ }
- return newDTO
+ return dto
}
func loadAllChannelListQueries() -> [ChannelListQueryDTO] {
diff --git a/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift b/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift
index 414988f456f..15003352ad5 100644
--- a/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift
+++ b/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift
@@ -7,6 +7,8 @@ import Foundation
@objc(CurrentUserDTO)
class CurrentUserDTO: NSManagedObject {
+ /// JSON-encoded `[groupKey: unreadCount]`.
+ @NSManaged var unreadGroupedChannelsCounts: Data?
@NSManaged var unreadChannelsCount: Int64
@NSManaged var unreadMessagesCount: Int64
@NSManaged var unreadThreadsCount: Int64
@@ -144,6 +146,30 @@ extension NSManagedObjectContext: CurrentUserDatabaseSession {
}
}
+ /// Merges per-group unread channel counts into `CurrentUserDTO.unreadChannelCountsByGroup`.
+ /// Called from `queryGroupedChannels` responses and from WS events carrying
+ /// `grouped_unread_channels`; both paths use merge semantics, so keys absent from the input
+ /// are left untouched and a group that disappears from a server snapshot will keep its
+ /// locally-cached count until something explicitly clears it.
+ func mergeCurrentUserUnreadChannelCountsByGroup(_ unreadChannelCountsByGroup: [String: Int]) throws {
+ invalidateCurrentUserCache()
+
+ guard let dto = currentUser else {
+ throw ClientError.CurrentUserDoesNotExist()
+ }
+
+ dto.unreadChannelCountsByGroup = (dto.unreadChannelCountsByGroup ?? [:]).merging(unreadChannelCountsByGroup) { _, new in new }
+ }
+
+ func adjustUnreadChannelCount(forGroup groupKey: String, by delta: Int) {
+ invalidateCurrentUserCache()
+ guard let dto = currentUser, var counts = dto.unreadChannelCountsByGroup, let existing = counts[groupKey] else {
+ return
+ }
+ counts[groupKey] = max(0, existing + delta)
+ dto.unreadChannelCountsByGroup = counts
+ }
+
func saveCurrentUserDevices(_ devices: [DevicePayload], clearExisting: Bool) throws -> [DeviceDTO] {
invalidateCurrentUserCache()
@@ -212,6 +238,18 @@ extension NSManagedObjectContext: CurrentUserDatabaseSession {
}
}
+extension CurrentUserDTO {
+ var unreadChannelCountsByGroup: [String: Int]? {
+ get {
+ guard let unreadGroupedChannelsCounts else { return nil }
+ return try? JSONDecoder.default.decode([String: Int].self, from: unreadGroupedChannelsCounts)
+ }
+ set {
+ unreadGroupedChannelsCounts = newValue.flatMap { try? JSONEncoder.default.encode($0) }
+ }
+ }
+}
+
extension CurrentUserDTO {
override class func prefetchedRelationshipKeyPaths() -> [String] {
[
@@ -282,6 +320,7 @@ extension CurrentChatUser {
messages: Int(dto.unreadMessagesCount),
threads: Int(dto.unreadThreadsCount)
),
+ unreadChannelCountsByGroup: dto.unreadChannelCountsByGroup,
mutedChannels: mutedChannels,
privacySettings: .init(
typingIndicators: .init(enabled: dto.isTypingIndicatorsEnabled),
diff --git a/Sources/StreamChat/Database/DatabaseSession.swift b/Sources/StreamChat/Database/DatabaseSession.swift
index da49deeb19a..a0b6c5a234d 100644
--- a/Sources/StreamChat/Database/DatabaseSession.swift
+++ b/Sources/StreamChat/Database/DatabaseSession.swift
@@ -58,6 +58,13 @@ protocol CurrentUserDatabaseSession {
/// If there is no current user, the error will be thrown.
func saveCurrentUserUnreadCount(count: UnreadCountPayload) throws
+ /// Merges per-group unread channel counts into `CurrentUserDTO.unreadChannelCountsByGroup`.
+ /// Keys present in the input replace existing values; keys absent are left untouched.
+ func mergeCurrentUserUnreadChannelCountsByGroup(_ unreadChannelCountsByGroup: [String: Int]) throws
+
+ /// Adjusts `CurrentUserDTO.unreadChannelCountsByGroup[groupKey]` by `delta`, flooring at 0.
+ func adjustUnreadChannelCount(forGroup groupKey: String, by delta: Int)
+
/// Updates the `CurrentUserDTO.devices` with the provided `DevicesPayload`
/// If there's no current user set, an error will be thrown.
@discardableResult
@@ -346,16 +353,21 @@ 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?
+
+ /// Returns the query with persisted predefined filter/sort applied.
+ /// `nil` when the input has no `predefinedFilter` or no cached DTO exists.
+ func loadPredefinedFilter(for query: ChannelListQuery) -> ChannelListQuery?
/// Loads all channel list queries from the database.
/// - Returns: The array of channel list queries.
func loadAllChannelListQueries() -> [ChannelListQueryDTO]
@discardableResult
- func saveQuery(query: ChannelListQuery) -> ChannelListQueryDTO
+ func saveQuery(query: ChannelListQuery, predefinedFilter: PredefinedFilterPayload?) -> ChannelListQueryDTO
/// Fetches `ChannelDTO` with the given `cid` from the database.
func channel(cid: ChannelId) -> ChannelDTO?
@@ -370,6 +382,13 @@ protocol ChannelDatabaseSession {
func deleteDraftMessage(in cid: ChannelId, threadId: MessageId?)
}
+extension ChannelDatabaseSession {
+ @discardableResult
+ func saveQuery(query: ChannelListQuery) -> ChannelListQueryDTO {
+ saveQuery(query: query, predefinedFilter: nil)
+ }
+}
+
protocol ChannelReadDatabaseSession {
/// Creates a new `ChannelReadDTO` object in the database. Throws an error if the ChannelRead fails to be created.
@discardableResult
@@ -746,6 +765,10 @@ extension DatabaseSession {
try saveCurrentUserUnreadCount(count: unreadCount)
}
+ if let unreadChannelCountsByGroup = payload.unreadChannelCountsByGroup {
+ try mergeCurrentUserUnreadChannelCountsByGroup(unreadChannelCountsByGroup)
+ }
+
if let threadDetailsPayload = payload.threadDetails?.value {
try saveThread(detailsPayload: threadDetailsPayload)
}
diff --git a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents
index 8addfeafeb0..2ff9ef7c207 100644
--- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents
+++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents
@@ -116,6 +116,10 @@
+
+
+
+
@@ -163,6 +167,7 @@
+
@@ -589,4 +594,4 @@
-
\ No newline at end of file
+
diff --git a/Sources/StreamChat/Generated/PredefinedFilter+Generated.swift b/Sources/StreamChat/Generated/PredefinedFilter+Generated.swift
new file mode 100644
index 00000000000..88ce129f14f
--- /dev/null
+++ b/Sources/StreamChat/Generated/PredefinedFilter+Generated.swift
@@ -0,0 +1,76 @@
+// Generated using Sourcery 2.3.0 — https://github.com/krzysztofzablocki/Sourcery
+// DO NOT EDIT
+
+// Run `make generate` after changing channel-list FilterKey or ChannelListSortingKey members.
+
+import Foundation
+
+extension ChannelListFilterScope {
+ /// Enrichment closures keyed by server-side `rawValue`; each re-attaches a `FilterKey`'s local
+ /// wiring (key path, value/predicate mappers) to a decoded predefined-filter leaf. Stored as
+ /// closures because the keys differ in their generic `Value` type.
+ static let predefinedFilterKeyMapping: [String: @Sendable (Filter) -> Filter] = {
+ func map(_ key: FilterKey) -> (String, @Sendable (Filter) -> Filter) {
+ (
+ key.rawValue,
+ { filter in
+ Filter(
+ operator: filter.operator,
+ key: filter.key,
+ value: filter.value,
+ valueMapper: key.valueMapper,
+ keyPathString: key.keyPathString,
+ isCollectionFilter: key.isCollectionFilter,
+ predicateMapper: key.predicateMapper
+ )
+ }
+ )
+ }
+ return Dictionary(uniqueKeysWithValues: [
+ map(.archived),
+ map(.blocked),
+ map(.channelRole),
+ map(.cid),
+ map(.createdAt),
+ map(.createdBy),
+ map(.deletedAt),
+ map(.disabled),
+ map(.filterTags),
+ map(.frozen),
+ map(.hasUnread),
+ map(.hidden),
+ map(.id),
+ map(.imageURL),
+ map(.invite),
+ map(.joined),
+ map(.lastMessageAt),
+ map(.lastUpdatedAt),
+ map(.memberCount),
+ map(.memberName),
+ map(.members),
+ map(.muted),
+ map(.name),
+ map(.pinned),
+ map(.team),
+ map(.type),
+ map(.updatedAt),
+ ])
+ }()
+}
+
+extension ChannelListSortingKey {
+ static let predefinedSortingKeyMapping: [String: Self] = {
+ func map(_ key: Self) -> (String, Self) { (key.remoteKey, key) }
+ return Dictionary(uniqueKeysWithValues: [
+ map(.default),
+ map(.cid),
+ map(.createdAt),
+ map(.hasUnread),
+ map(.lastMessageAt),
+ map(.memberCount),
+ map(.pinnedAt),
+ map(.unreadCount),
+ map(.updatedAt),
+ ])
+ }()
+}
diff --git a/Sources/StreamChat/Generated/PredefinedFilter.stencil b/Sources/StreamChat/Generated/PredefinedFilter.stencil
new file mode 100644
index 00000000000..f587af0b4c6
--- /dev/null
+++ b/Sources/StreamChat/Generated/PredefinedFilter.stencil
@@ -0,0 +1,39 @@
+// Run `make generate` after changing channel-list FilterKey or ChannelListSortingKey members.
+
+import Foundation
+
+extension ChannelListFilterScope {
+ /// Enrichment closures keyed by server-side `rawValue`; each re-attaches a `FilterKey`'s local
+ /// wiring (key path, value/predicate mappers) to a decoded predefined-filter leaf. Stored as
+ /// closures because the keys differ in their generic `Value` type.
+ static let predefinedFilterKeyMapping: [String: @Sendable (Filter) -> Filter] = {
+ func map(_ key: FilterKey) -> (String, @Sendable (Filter) -> Filter) {
+ (
+ key.rawValue,
+ { filter in
+ Filter(
+ operator: filter.operator,
+ key: filter.key,
+ value: filter.value,
+ valueMapper: key.valueMapper,
+ keyPathString: key.keyPathString,
+ isCollectionFilter: key.isCollectionFilter,
+ predicateMapper: key.predicateMapper
+ )
+ }
+ )
+ }
+ return Dictionary(uniqueKeysWithValues: [
+{% for type in types.all %}{% if type.name == "FilterKey" %}{% for variable in type.staticVariables|sorted:"name" %} map(.{{ variable.name|replace:"`","" }}),
+{% endfor %}{% endif %}{% endfor %} ])
+ }()
+}
+
+extension ChannelListSortingKey {
+ static let predefinedSortingKeyMapping: [String: Self] = {
+ func map(_ key: Self) -> (String, Self) { (key.remoteKey, key) }
+ return Dictionary(uniqueKeysWithValues: [
+{% for type in types.all %}{% for variable in type.staticVariables|sorted:"name" %}{% if variable.definedInTypeName.name == "ChannelListSortingKey" and variable.typeName.name == "Self" %} map(.{{ variable.name|replace:"`","" }}),
+{% endif %}{% endfor %}{% endfor %} ])
+ }()
+}
diff --git a/Sources/StreamChat/Generated/SystemEnvironment+Version.swift b/Sources/StreamChat/Generated/SystemEnvironment+Version.swift
index b4b835a8545..43ffa8621d5 100644
--- a/Sources/StreamChat/Generated/SystemEnvironment+Version.swift
+++ b/Sources/StreamChat/Generated/SystemEnvironment+Version.swift
@@ -7,5 +7,5 @@ import Foundation
extension SystemEnvironment {
/// A Stream Chat version.
- public static let version: String = "5.4.1"
+ public static let version: String = "5.5.0"
}
diff --git a/Sources/StreamChat/Info.plist b/Sources/StreamChat/Info.plist
index 527d28b1e68..c1d1bf86199 100644
--- a/Sources/StreamChat/Info.plist
+++ b/Sources/StreamChat/Info.plist
@@ -15,7 +15,7 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 5.4.1
+ 5.5.0
CFBundleVersion
$(CURRENT_PROJECT_VERSION)
diff --git a/Sources/StreamChat/Models/ChannelGroup.swift b/Sources/StreamChat/Models/ChannelGroup.swift
new file mode 100644
index 00000000000..c7c4d92d243
--- /dev/null
+++ b/Sources/StreamChat/Models/ChannelGroup.swift
@@ -0,0 +1,43 @@
+//
+// Copyright © 2026 Stream.io Inc. All rights reserved.
+//
+
+import Foundation
+
+/// A channel group returned by ``ChatClient/queryGroupedChannels(groups:limit:presence:watch:)``.
+///
+/// To observe and read the channels that belong to a group, create a
+/// ``ChannelList`` with ``ChatClient/makeChannelList(with:)-(String)`` and read
+/// ``ChannelListState/channels`` from its ``ChannelList/state``.
+public struct ChannelGroup: Sendable {
+ /// The group key as returned by the backend (e.g. `"all"`, `"new"`, `"old"`, `"current"`).
+ public let groupKey: String
+
+ /// The total unread channel count in the group.
+ public let unreadChannels: Int
+
+ /// Channels returned by the request.
+ public let channels: [ChatChannel]
+
+ let next: String?
+
+ init(
+ groupKey: String,
+ channels: [ChatChannel],
+ unreadChannels: Int,
+ next: String? = nil
+ ) {
+ self.groupKey = groupKey
+ self.channels = channels
+ self.unreadChannels = unreadChannels
+ self.next = next
+ }
+}
+
+/// Constants used by the grouped channels feature.
+enum GroupedChannelKey {
+ /// Special group key whose list contains channels from every other group.
+ static let all = "all"
+ /// `ChatChannel.extraData` field that carries the channel's group membership.
+ static let group = "group"
+}
diff --git a/Sources/StreamChat/Models/CurrentUser.swift b/Sources/StreamChat/Models/CurrentUser.swift
index b675c497207..7f8af391c1b 100644
--- a/Sources/StreamChat/Models/CurrentUser.swift
+++ b/Sources/StreamChat/Models/CurrentUser.swift
@@ -55,6 +55,9 @@ public class CurrentChatUser: ChatUser, @unchecked Sendable {
/// The unread counts for the current user.
public let unreadCount: UnreadCount
+ /// Unread channel counts keyed by the backend-provided group identifier.
+ public let unreadChannelCountsByGroup: [String: Int]?
+
/// A Boolean value indicating if the user has opted to hide their online status.
public let isInvisible: Bool
@@ -87,6 +90,7 @@ public class CurrentChatUser: ChatUser, @unchecked Sendable {
flaggedUsers: Set,
flaggedMessageIDs: Set,
unreadCount: UnreadCount,
+ unreadChannelCountsByGroup: [String: Int]? = nil,
mutedChannels: Set,
privacySettings: UserPrivacySettings,
avgResponseTime: Int?,
@@ -99,6 +103,7 @@ public class CurrentChatUser: ChatUser, @unchecked Sendable {
self.flaggedUsers = flaggedUsers
self.flaggedMessageIDs = flaggedMessageIDs
self.unreadCount = unreadCount
+ self.unreadChannelCountsByGroup = unreadChannelCountsByGroup
self.isInvisible = isInvisible
self.privacySettings = privacySettings
self.mutedChannels = mutedChannels
diff --git a/Sources/StreamChat/Query/ChannelListQuery+PredefinedFilter.swift b/Sources/StreamChat/Query/ChannelListQuery+PredefinedFilter.swift
new file mode 100644
index 00000000000..db89dc2960d
--- /dev/null
+++ b/Sources/StreamChat/Query/ChannelListQuery+PredefinedFilter.swift
@@ -0,0 +1,63 @@
+//
+// Copyright © 2026 Stream.io Inc. All rights reserved.
+//
+
+import Foundation
+import StreamCore
+
+// MARK: - Filter
+
+extension Filter where Scope == ChannelListFilterScope {
+ /// Decodes a channel-list filter from persisted JSON and re-attaches Core Data wiring
+ /// (keyPath, valueMapper, predicateMapper) for every node whose key matches a known
+ /// `FilterKey`. Unknown keys pass through unchanged.
+ static func predefinedFilter(fromJSONData data: Data) throws -> Filter? {
+ guard !data.isEmpty else { return nil }
+ let decoded = try JSONDecoder.default.decode(Filter.self, from: data)
+ return decoded.applyCoreDataFilteringKeys()
+ }
+
+ /// Walks the filter tree and replaces each leaf with an enriched copy that carries
+ /// the Core Data wiring for its key. Group operators (`$and` / `$or` / `$nor`) recurse.
+ private func applyCoreDataFilteringKeys() -> Filter {
+ if `operator`.isGroupOperator {
+ guard let children = value as? [Filter] else {
+ return self
+ }
+ return Filter(
+ operator: `operator`,
+ key: key,
+ value: children.map { $0.applyCoreDataFilteringKeys() },
+ isCollectionFilter: isCollectionFilter
+ )
+ }
+ guard let key else { return self }
+ guard let mapFilter = ChannelListFilterScope.predefinedFilterKeyMapping[key] else {
+ StreamCore.log.error("Can't apply CoreData keyPath for channel list filtering key '\(key)'.")
+ return self
+ }
+ return mapFilter(self)
+ }
+}
+
+// MARK: - Sort
+
+extension Array where Element == Sorting {
+ /// Decodes a server-resolved sort array (`[{"field": ..., "direction": -1|1, ...}, ...]`).
+ static func predefinedFilterSort(fromJSONData data: Data) throws -> [Sorting]? {
+ guard !data.isEmpty else { return nil }
+ let raw = try JSONDecoder.default.decode([RawSortingItem].self, from: data)
+ return raw.compactMap { item in
+ guard let key = ChannelListSortingKey.predefinedSortingKeyMapping[item.field] else {
+ StreamCore.log.error("Can't apply CoreData keyPath for channel list sorting key '\(item.field)'.")
+ return nil
+ }
+ return Sorting(key: key, isAscending: item.direction == 1)
+ }
+ }
+}
+
+private struct RawSortingItem: Decodable {
+ let field: String
+ let direction: Int
+}
diff --git a/Sources/StreamChat/Query/ChannelListQuery.swift b/Sources/StreamChat/Query/ChannelListQuery.swift
index fe1f810e1c2..fbba8308d00 100644
--- a/Sources/StreamChat/Query/ChannelListQuery.swift
+++ b/Sources/StreamChat/Query/ChannelListQuery.swift
@@ -17,12 +17,15 @@ public struct ChannelListQuery: Encodable, Sendable, LocalConvertibleSortingQuer
case pagination
case messagesLimit = "message_limit"
case membersLimit = "member_limit"
+ case predefinedFilter = "predefined_filter"
+ case filterValues = "filter_values"
+ case sortValues = "sort_values"
}
/// A filter for the query (see `Filter`).
- public let filter: Filter
+ public internal(set) var filter: Filter
/// A sorting for the query (see `Sorting`).
- public let sort: [Sorting]
+ public internal(set) var sort: [Sorting]
/// A pagination.
public var pagination: Pagination
/// A number of messages inside each channel.
@@ -31,6 +34,15 @@ public struct ChannelListQuery: Encodable, Sendable, LocalConvertibleSortingQuer
public let membersLimit: Int?
/// Query options.
public var options: QueryOptions = [.watch]
+ /// The name of a server-side predefined filter to apply to this query.
+ ///
+ /// When set, the filter and sort templates configured for the predefined filter on the server
+ /// are used, and `filter` / `sort` on this query are ignored by the server.
+ public let predefinedFilter: String?
+ /// Values substituted into the predefined filter's filter template placeholders.
+ public let filterValues: [String: RawJSON]?
+ /// Values substituted into the predefined filter's sort template placeholders.
+ public let sortValues: [String: RawJSON]?
/// Init a channels query.
/// - Parameters:
@@ -51,14 +63,64 @@ public struct ChannelListQuery: Encodable, Sendable, LocalConvertibleSortingQuer
pagination = Pagination(pageSize: pageSize)
self.messagesLimit = messagesLimit
self.membersLimit = membersLimit
+ predefinedFilter = nil
+ filterValues = nil
+ sortValues = nil
+ }
+
+ /// Init a channels query that uses a server-side predefined filter.
+ ///
+ /// The predefined filter's filter and sort templates (configured server-side) determine the
+ /// effective filter and sort. Placeholders in those templates are substituted using
+ /// `filterValues` and `sortValues`.
+ ///
+ /// - Parameters:
+ /// - predefinedFilter: name of the server-side predefined filter to apply.
+ /// - filterValues: values substituted into the predefined filter's filter template placeholders.
+ /// - sortValues: values substituted into the predefined filter's sort template placeholders.
+ /// - pageSize: a page size for pagination.
+ /// - messagesLimit: a number of messages for the channel to be retrieved. Pass `nil` to omit the request value.
+ /// - membersLimit: a number of members for the channel to be retrieved. Pass `nil` to omit the request value.
+ public init(
+ predefinedFilter: String,
+ filterValues: [String: RawJSON]? = nil,
+ sortValues: [String: RawJSON]? = nil,
+ pageSize: Int = .channelsPageSize,
+ messagesLimit: Int? = nil,
+ membersLimit: Int? = nil
+ ) {
+ filter = .and([])
+ sort = []
+ pagination = Pagination(pageSize: pageSize)
+ self.messagesLimit = messagesLimit
+ self.membersLimit = membersLimit
+ self.predefinedFilter = predefinedFilter
+ self.filterValues = filterValues
+ self.sortValues = sortValues
+ }
+
+ init(groupKey: String) {
+ self.init(filter: .empty)
+ self.groupKey = groupKey
+ self.pagination = Pagination(pageSize: .backendDefaultPageSize)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
- try container.encode(filter, forKey: .filter)
- if !sort.isEmpty {
- try container.encode(sort, forKey: .sort)
+ if let predefinedFilter, !predefinedFilter.isEmpty {
+ try container.encode(predefinedFilter, forKey: .predefinedFilter)
+ if let filterValues, !filterValues.isEmpty {
+ try container.encode(filterValues, forKey: .filterValues)
+ }
+ if let sortValues, !sortValues.isEmpty {
+ try container.encode(sortValues, forKey: .sortValues)
+ }
+ } else {
+ try container.encode(filter, forKey: .filter)
+ if !sort.isEmpty {
+ try container.encode(sort, forKey: .sort)
+ }
}
if let messagesLimit {
@@ -71,6 +133,41 @@ public struct ChannelListQuery: Encodable, Sendable, LocalConvertibleSortingQuer
try options.encode(to: encoder)
try pagination.encode(to: encoder)
}
+
+ var groupKey: String?
+
+ /// The stable identity used for locating / linking the corresponding `ChannelListQueryDTO`.
+ ///
+ /// For grouped queries the hash is the `groupKey` (grouped channels ignore filter and sort).
+ /// For predefined-filter queries it is derived from the predefined filter name plus
+ /// `filterValues` and `sortValues` (keys sorted to keep the hash deterministic). For
+ /// traditional queries it falls back to `filter.filterHash`, leaving existing on-disk
+ /// hashes unchanged.
+ var queryHash: String {
+ if let groupKey {
+ return groupKey
+ }
+ if let predefinedFilter, !predefinedFilter.isEmpty {
+ return [
+ predefinedFilter,
+ filterValues.flatMap { $0.isEmpty ? nil : $0.sortedDescription },
+ sortValues.flatMap { $0.isEmpty ? nil : $0.sortedDescription }
+ ]
+ .compactMap { $0 }
+ .joined(separator: "-")
+ }
+ return filter.filterHash
+ }
+}
+
+extension ChannelListQuery {
+ /// Whether `filter` and `sort` match `other` for purposes of deciding whether the local
+ /// observer needs to be rebuilt after a predefined-filter resolution.
+ func isFilterEqual(to other: ChannelListQuery) -> Bool {
+ guard filter.filterHash == other.filter.filterHash else { return false }
+ guard sort.map(\.description) == other.sort.map(\.description) else { return false }
+ return true
+ }
}
extension ChannelListQuery: CustomDebugStringConvertible {
diff --git a/Sources/StreamChat/Query/Filter+predicate.swift b/Sources/StreamChat/Query/Filter+predicate.swift
index 8b6d90a9189..983a7ae9b7f 100644
--- a/Sources/StreamChat/Query/Filter+predicate.swift
+++ b/Sources/StreamChat/Query/Filter+predicate.swift
@@ -11,6 +11,10 @@ extension Filter {
///
/// **Note:** Extra data properties will be ignored since they are stored in binary format.
var predicate: NSPredicate? {
+ // `Filter.empty` (`.and([])`) imposes no constraint — no predicate to apply.
+ if let filters = value as? [Filter], filters.isEmpty {
+ return nil
+ }
guard let op = FilterOperator(rawValue: `operator`) else {
return nil
}
diff --git a/Sources/StreamChat/Query/Filter.swift b/Sources/StreamChat/Query/Filter.swift
index 41d38385cbf..9e4f7bceac4 100644
--- a/Sources/StreamChat/Query/Filter.swift
+++ b/Sources/StreamChat/Query/Filter.swift
@@ -218,6 +218,11 @@ public extension Filter {
}
}
+extension Filter {
+ /// A filter placeholder value that represents a grouped channels query.
+ static var empty: Filter { .and([]) }
+}
+
/// A helper struct that represents a key of a filter.
///
/// It allows tagging a key with a scope and a type of the value the key is related to.
@@ -507,35 +512,75 @@ extension Filter: Codable {
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: ArbitraryKey.self)
- for key in container.allKeys {
- if key.stringValue.hasPrefix("$") {
- // The right side should be an array of other filters
- let filters = try container.decode([Filter].self, forKey: key)
- self.init(
- operator: key.stringValue,
- key: nil,
- value: filters,
- isCollectionFilter: false
- )
- return
-
- } else {
- // The right side should be FilterRightSide
- let rightSide = try container.decode(FilterRightSide.self, forKey: key)
- self.init(
- operator: rightSide.operator,
- key: key.stringValue,
- value: rightSide.value,
- isCollectionFilter: false
- )
- return
- }
+ let keys = container.allKeys
+
+ guard let firstKey = keys.first else {
+ throw DecodingError.dataCorruptedError(
+ forKey: ArbitraryKey(""),
+ in: container,
+ debugDescription: "Filter logic structure is incorrect"
+ )
}
- throw DecodingError.dataCorruptedError(
- forKey: container.allKeys.last ?? ArbitraryKey(""),
- in: container,
- debugDescription: "Filter logic structure is incorrect"
+ // Multiple keys at one level are implicitly ANDed (Mongo-style), whether each key is a
+ // field (`{"type": "messaging"}`) or a group operator (`{"$or": [...]}`). This mirrors the
+ // backend, whose query parser ANDs every top-level key regardless of kind. Keys are sorted
+ // so the decoded tree is stable regardless of `allKeys` ordering.
+ if keys.count > 1 {
+ self.init(
+ operator: FilterOperator.and.rawValue,
+ key: nil,
+ value: try keys
+ .sorted { $0.stringValue < $1.stringValue }
+ .map { try Self.decodeNode(in: container, forKey: $0) },
+ isCollectionFilter: false
+ )
+ return
+ }
+
+ self = try Self.decodeNode(in: container, forKey: firstKey)
+ }
+
+ /// Decodes a single key as either a group-operator node (`$and`/`$or`/`$nor`, whose value is an
+ /// array of sub-filters) or a leaf (`{field: value}` / `{field: {$op: value}}`).
+ private static func decodeNode(
+ in container: KeyedDecodingContainer,
+ forKey key: ArbitraryKey
+ ) throws -> Filter {
+ if key.stringValue.hasPrefix("$") {
+ // The right side should be an array of other filters
+ let filters = try container.decode([Filter].self, forKey: key)
+ return Filter(
+ operator: key.stringValue,
+ key: nil,
+ value: filters,
+ isCollectionFilter: false
+ )
+ }
+ return try decodeLeaf(in: container, forKey: key)
+ }
+
+ private static func decodeLeaf(
+ in container: KeyedDecodingContainer,
+ forKey key: ArbitraryKey
+ ) throws -> Filter {
+ // Long form: { key: { $op: value } }
+ if let rightSide = try? container.decode(FilterRightSide.self, forKey: key) {
+ return Filter(
+ operator: rightSide.operator,
+ key: key.stringValue,
+ value: rightSide.value,
+ isCollectionFilter: false
+ )
+ }
+
+ // Short form (implicit $eq): { key: value }
+ let value = try container.decodeFilterValue(forKey: key)
+ return Filter(
+ operator: FilterOperator.equal.rawValue,
+ key: key.stringValue,
+ value: value,
+ isCollectionFilter: false
)
}
}
@@ -590,34 +635,37 @@ private struct FilterRightSide: Decodable {
}
self.operator = container.allKeys.first!.stringValue
- var value: FilterValue?
-
- if let intValue = try? container.decode(Int.self, forKey: key) {
- value = intValue
- } else if let doubleValue = try? container.decode(Double.self, forKey: key) {
- value = doubleValue
- } else if let dateValue = try? container.decode(Date.self, forKey: key) {
- value = dateValue
- } else if let stringValue = try? container.decode(String.self, forKey: key) {
- value = stringValue
- } else if let boolValue = try? container.decode(Bool.self, forKey: key) {
- value = boolValue
- } else if let stringArray = try? container.decode([String].self, forKey: key) {
- value = stringArray
- } else if let intArray = try? container.decode([Int].self, forKey: key) {
- value = intArray
- } else if let doubleArray = try? container.decode([Double].self, forKey: key) {
- value = doubleArray
- }
+ self.value = try container.decodeFilterValue(forKey: key)
+ }
+}
- if let value = value {
- self.value = value
- } else {
- throw DecodingError.dataCorruptedError(
- forKey: key,
- in: container,
- debugDescription: "The data can't be decoded as `FilterValue`."
- )
+private extension KeyedDecodingContainer {
+ /// Decodes a scalar (or homogeneous array) JSON value as `FilterValue`.
+ func decodeFilterValue(forKey key: Key) throws -> FilterValue {
+ if (try? decodeNil(forKey: key)) == true {
+ // `Optional` is the only `FilterValue`-conforming optional (see line 86).
+ return TeamId?.none
+ } else if let intValue = try? decode(Int.self, forKey: key) {
+ return intValue
+ } else if let doubleValue = try? decode(Double.self, forKey: key) {
+ return doubleValue
+ } else if let dateValue = try? decode(Date.self, forKey: key) {
+ return dateValue
+ } else if let stringValue = try? decode(String.self, forKey: key) {
+ return stringValue
+ } else if let boolValue = try? decode(Bool.self, forKey: key) {
+ return boolValue
+ } else if let stringArray = try? decode([String].self, forKey: key) {
+ return stringArray
+ } else if let intArray = try? decode([Int].self, forKey: key) {
+ return intArray
+ } else if let doubleArray = try? decode([Double].self, forKey: key) {
+ return doubleArray
}
+ throw DecodingError.dataCorruptedError(
+ forKey: key,
+ in: self,
+ debugDescription: "The data can't be decoded as `FilterValue`."
+ )
}
}
diff --git a/Sources/StreamChat/Query/Pagination.swift b/Sources/StreamChat/Query/Pagination.swift
index f652ac0e635..2f54aa477fb 100644
--- a/Sources/StreamChat/Query/Pagination.swift
+++ b/Sources/StreamChat/Query/Pagination.swift
@@ -17,6 +17,11 @@ public extension Int {
static let channelWatchersPageSize = 30
}
+extension Int {
+ /// Page size value for using the backend default.
+ static let backendDefaultPageSize = -1
+}
+
/// Basic pagination with `pageSize` and `offset`.
/// Used everywhere except `ChannelQuery`. (See `MessagesPagination`)
public struct Pagination: Encodable, Equatable, Sendable {
@@ -47,7 +52,9 @@ public struct Pagination: Encodable, Equatable, Sendable {
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
- try container.encode(pageSize, forKey: .pageSize)
+ if pageSize != .backendDefaultPageSize {
+ try container.encode(pageSize, forKey: .pageSize)
+ }
if let cursor = cursor {
try container.encode(cursor, forKey: .cursor)
} else if offset != 0 {
diff --git a/Sources/StreamChat/Repositories/SyncOperations.swift b/Sources/StreamChat/Repositories/SyncOperations.swift
index 78236cc9695..4335696d1ab 100644
--- a/Sources/StreamChat/Repositories/SyncOperations.swift
+++ b/Sources/StreamChat/Repositories/SyncOperations.swift
@@ -98,14 +98,58 @@ final class RefreshChannelListOperation: AsyncOperation, @unchecked Sendable {
done(.continue)
return
}
+ guard channelList.groupKey == nil else {
+ done(.continue)
+ return
+ }
Task {
do {
let channelIds = try await channelList.refreshLoadedChannels()
- log.debug("Synced \(channelIds.count) channels in a channel list (\(channelList.query.filter)", subsystems: .offlineSupport)
+ log.debug("Synced \(channelIds.count) channels in a channel list", subsystems: .offlineSupport)
context.synchedChannelIds.formUnion(channelIds)
done(.continue)
} catch {
- log.error("Failed refreshing channel list (\(channelList.query.filter) with error \(error)", subsystems: .offlineSupport)
+ log.error("Failed refreshing channel list with error \(error)", subsystems: .offlineSupport)
+ done(.retry)
+ }
+ }
+ }
+ }
+}
+
+/// Refreshes all grouped channel lists with a single batched `queryGroupedChannels` call, recording the
+/// returned channel ids in `context.synchedChannelIds` so the subsequent `/sync` step skips them.
+final class SyncGroupedChannelsOperation: AsyncOperation, @unchecked Sendable {
+ init(channelListUpdater: ChannelListUpdater, groupedChannelLists: [ChannelList], context: SyncContext) {
+ let groupKeys = Set(groupedChannelLists.compactMap(\.groupKey))
+ super.init(maxRetries: syncOperationsMaximumRetries) { [weak channelListUpdater] _, done in
+ // All grouped lists share the same persisted flags (set together by the initial
+ // `queryGroupedChannels` call), so any one of them is a valid source. `sorted().first`
+ // keeps the choice deterministic across runs.
+ guard let channelListUpdater, let sampleGroupKey = groupKeys.sorted().first else {
+ done(.continue)
+ return
+ }
+
+ Task {
+ do {
+ let state = try await channelListUpdater.paginationState(for: sampleGroupKey)
+ let groups = Dictionary(uniqueKeysWithValues: groupKeys.map { ($0, GroupedQueryChannelsRequestGroup(limit: nil, next: nil)) })
+ let channelGroups = try await channelListUpdater.queryGroupedChannels(
+ groups: groups,
+ limit: nil,
+ watch: state.watch ?? false,
+ presence: state.presence ?? false
+ )
+ let returnedChannelIds = channelGroups.flatMap { $0.channels.map(\.cid) }
+ context.synchedChannelIds.formUnion(returnedChannelIds)
+ log.debug(
+ "Synced \(returnedChannelIds.count) grouped channels across \(channelGroups.count) group(s)",
+ subsystems: .offlineSupport
+ )
+ done(.continue)
+ } catch {
+ log.error("Failed to refresh grouped channels during sync: \(error)", subsystems: .offlineSupport)
done(.retry)
}
}
diff --git a/Sources/StreamChat/Repositories/SyncRepository.swift b/Sources/StreamChat/Repositories/SyncRepository.swift
index 91597922e73..4cfbff20c2a 100644
--- a/Sources/StreamChat/Repositories/SyncRepository.swift
+++ b/Sources/StreamChat/Repositories/SyncRepository.swift
@@ -171,7 +171,7 @@ class SyncRepository: @unchecked Sendable {
///
/// Background mode (other regular API requests are allowed to run at the same time)
/// 1. Collect all the **active** channel ids (from instances of `Chat`, `ChannelList`, `ChatChannelController`, `ChatChannelListController`)
- /// 2. Refresh channel lists (channels for current pages in `ChannelList`, `ChatChannelListController`)
+ /// 2. Refresh channel lists (channels for current pages in `ChannelList`, `ChatChannelListController`, including grouped lists)
/// 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
@@ -201,10 +201,24 @@ class SyncRepository: @unchecked Sendable {
/// 1. Collect all the **active** channel ids
operations.append(ActiveChannelIdsOperation(syncRepository: self, context: context))
- // 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) })
-
+ // 2. Refresh channel lists (non-grouped lists individually, grouped lists via a single shared request)
+ let allChannelLists = activeChannelLists.allObjects
+ operations.append(contentsOf: allChannelLists
+ .filter { $0.groupKey == nil }
+ .map { RefreshChannelListOperation(channelList: $0, context: context) }
+ )
+ operations.append(contentsOf: activeChannelListControllers.allObjects
+ .map { RefreshChannelListOperation(controller: $0, context: context) }
+ )
+ let groupedChannelLists = allChannelLists.filter { $0.groupKey != nil }
+ if !groupedChannelLists.isEmpty {
+ operations.append(SyncGroupedChannelsOperation(
+ channelListUpdater: channelListUpdater,
+ groupedChannelLists: groupedChannelLists,
+ context: context
+ ))
+ }
+
// 3. /sync (for channels what not part of active channel lists)
operations.append(SyncEventsOperation(syncRepository: self, context: context, recovery: false))
diff --git a/Sources/StreamChat/StateLayer/ChannelList.swift b/Sources/StreamChat/StateLayer/ChannelList.swift
index 7583c882fb8..28c4760613b 100644
--- a/Sources/StreamChat/StateLayer/ChannelList.swift
+++ b/Sources/StreamChat/StateLayer/ChannelList.swift
@@ -9,8 +9,8 @@ public class ChannelList: @unchecked Sendable {
private let channelListUpdater: ChannelListUpdater
private let client: ChatClient
@MainActor private var stateBuilder: StateBuilder
- let query: ChannelListQuery
-
+ let groupKey: String?
+
init(
query: ChannelListQuery,
dynamicFilter: (@Sendable (ChatChannel) -> Bool)?,
@@ -18,7 +18,7 @@ public class ChannelList: @unchecked Sendable {
environment: Environment = .init()
) {
self.client = client
- self.query = query
+ self.groupKey = query.groupKey
let channelListUpdater = environment.channelListUpdater(
client.databaseContainer,
client.apiClient
@@ -36,25 +36,25 @@ public class ChannelList: @unchecked Sendable {
)
}
}
-
+
// MARK: - Accessing the State
-
+
/// An observable object representing the current state of the channel list.
@MainActor public var state: ChannelListState { stateBuilder.state }
-
+
/// Fetches the most recent state from the server and updates the local store.
///
/// - Important: Loaded channels in ``ChannelListState/channels`` are reset.
///
/// - Throws: An error while communicating with the Stream API.
public func get() async throws {
- let pagination = Pagination(pageSize: query.pagination.pageSize)
+ let pagination = Pagination(pageSize: await state.query.pagination.pageSize)
try await loadChannels(with: pagination)
client.syncRepository.startTrackingChannelList(self)
}
-
+
// MARK: - Channel List Pagination
-
+
/// Loads channels for the specified pagination parameters and updates ``ChannelListState/channels``.
///
/// - Important: If the pagination offset is 0 and cursor is nil, then loaded channels are reset.
@@ -64,9 +64,28 @@ public class ChannelList: @unchecked Sendable {
/// - Throws: An error while communicating with the Stream API.
/// - Returns: An array of channels for the pagination.
@discardableResult public func loadChannels(with pagination: Pagination) async throws -> [ChatChannel] {
- try await channelListUpdater.loadChannels(query: query, pagination: pagination)
+ if let groupKey {
+ let paginationState = try await channelListUpdater.paginationState(for: groupKey)
+ let channelGroups = try await channelListUpdater.queryGroupedChannels(
+ groups: [groupKey: .init(limit: pagination.pageSize > 0 ? pagination.pageSize : nil, next: pagination.cursor)],
+ limit: nil,
+ watch: paginationState.watch ?? true,
+ presence: paginationState.presence ?? false
+ )
+ let group = channelGroups.first { $0.groupKey == groupKey }
+ await setHasLoadedAllPreviousChannels(group?.next == nil)
+ return group?.channels ?? []
+ } else {
+ var query = await state.query
+ query.pagination = pagination
+ let result = try await channelListUpdater.update(channelListQuery: query)
+ if let updatedQuery = result.updatedQuery {
+ await state.setQuery(updatedQuery)
+ }
+ return result.channels
+ }
}
-
+
/// Loads more channels and updates ``ChannelListState/channels``.
///
/// - Parameter limit: The limit for the page size. The default limit is 20.
@@ -74,21 +93,35 @@ public class ChannelList: @unchecked Sendable {
/// - Throws: An error while communicating with the Stream API.
/// - Returns: An array of loaded channels.
@discardableResult public func loadMoreChannels(limit: Int? = nil) async throws -> [ChatChannel] {
- let limit = limit ?? query.pagination.pageSize
- let count = await state.channels.count
- return try await channelListUpdater.loadNextChannels(
- query: query,
- limit: limit,
- loadedChannelsCount: count
- )
+ guard await !state.hasLoadedAllPreviousChannels else { return [] }
+ let pageSize = await state.query.pagination.pageSize
+ let limit = limit ?? pageSize
+ if let groupKey {
+ let paginationState = try await channelListUpdater.paginationState(for: groupKey)
+ guard let cursor = paginationState.next else {
+ await setHasLoadedAllPreviousChannels(true)
+ return []
+ }
+ return try await loadChannels(with: Pagination(pageSize: limit, cursor: cursor))
+ } else {
+ let count = await state.channels.count
+ let channels = try await loadChannels(with: Pagination(pageSize: limit, offset: count))
+ await setHasLoadedAllPreviousChannels(channels.isEmpty || channels.count < limit)
+ return channels
+ }
}
-
+
// MARK: - Internal
-
+
func refreshLoadedChannels() async throws -> Set {
let count = await state.channels.count
+ let query = await state.query
return try await channelListUpdater.refreshLoadedChannels(for: query, channelCount: count)
}
+
+ @MainActor private func setHasLoadedAllPreviousChannels(_ hasLoadedAllPreviousChannels: Bool) {
+ state.hasLoadedAllPreviousChannels = hasLoadedAllPreviousChannels
+ }
}
extension ChannelList {
@@ -97,7 +130,7 @@ extension ChannelList {
_ database: DatabaseContainer,
_ apiClient: APIClient
) -> ChannelListUpdater = { ChannelListUpdater(database: $0, apiClient: $1) }
-
+
var stateBuilder: @Sendable @MainActor (
_ query: ChannelListQuery,
_ dynamicFilter: (@Sendable (ChatChannel) -> Bool)?,
diff --git a/Sources/StreamChat/StateLayer/ChannelListState+Observer.swift b/Sources/StreamChat/StateLayer/ChannelListState+Observer.swift
index f02c61fc9c9..ebaca88429a 100644
--- a/Sources/StreamChat/StateLayer/ChannelListState+Observer.swift
+++ b/Sources/StreamChat/StateLayer/ChannelListState+Observer.swift
@@ -6,14 +6,15 @@ import Foundation
extension ChannelListState {
final class Observer {
- private let channelListObserver: StateLayerDatabaseObserver
+ private var channelListObserver: StateLayerDatabaseObserver
private let clientConfig: ChatClientConfig
- private let channelListLinker: ChannelListLinker
+ private let channelListLinker: ChannelListLinking
private let channelListUpdater: ChannelListUpdater
private let database: DatabaseContainer
private let dynamicFilter: ((ChatChannel) -> Bool)?
private let eventNotificationCenter: EventNotificationCenter
- private let query: ChannelListQuery
+ private var query: ChannelListQuery
+ private var channelsDidChange: (@Sendable @MainActor ([ChatChannel]) async -> Void)?
init(
query: ChannelListQuery,
@@ -31,24 +32,27 @@ extension ChannelListState {
self.query = query
self.eventNotificationCenter = eventNotificationCenter
- channelListObserver = StateLayerDatabaseObserver(
+ channelListObserver = Self.makeChannelListObserver(
+ for: query,
database: database,
- fetchRequest: ChannelDTO.channelListFetchRequest(
- query: query,
- chatClientConfig: clientConfig
- ),
- itemCreator: { try $0.asModel() },
- itemReuseKeyPaths: (\ChatChannel.cid.rawValue, \ChannelDTO.cid),
- runtimeSorting: query.runtimeSortingValues
- )
- channelListLinker = ChannelListLinker(
- query: query,
- filter: dynamicFilter,
- clientConfig: clientConfig,
- databaseContainer: database,
- worker: channelListUpdater,
- channelWatcherHandler: channelWatcherHandler
+ clientConfig: clientConfig
)
+ if query.groupKey == nil {
+ channelListLinker = ChannelListLinker(
+ query: query,
+ filter: dynamicFilter,
+ clientConfig: clientConfig,
+ databaseContainer: database,
+ worker: channelListUpdater,
+ channelWatcherHandler: channelWatcherHandler
+ )
+ } else {
+ channelListLinker = GroupedChannelListLinker(
+ query: query,
+ databaseContainer: database,
+ channelWatcherHandler: channelWatcherHandler
+ )
+ }
}
struct Handlers {
@@ -56,6 +60,7 @@ extension ChannelListState {
}
func start(with handlers: Handlers) -> [ChatChannel] {
+ channelsDidChange = handlers.channelsDidChange
do {
channelListLinker.start(with: eventNotificationCenter)
return try channelListObserver.startObserving(didChange: handlers.channelsDidChange)
@@ -64,5 +69,42 @@ extension ChannelListState {
return []
}
}
+
+ func reload(with newQuery: ChannelListQuery) -> [ChatChannel] {
+ query = newQuery
+ channelListObserver = Self.makeChannelListObserver(
+ for: newQuery,
+ database: database,
+ clientConfig: clientConfig
+ )
+ guard let channelsDidChange else { return [] }
+ do {
+ return try channelListObserver.startObserving(didChange: channelsDidChange)
+ } catch {
+ log.error("Failed to restart the channel list observer after reload for query: \(newQuery)")
+ return []
+ }
+ }
+
+ private static func makeChannelListObserver(
+ for query: ChannelListQuery,
+ database: DatabaseContainer,
+ clientConfig: ChatClientConfig
+ ) -> StateLayerDatabaseObserver {
+ StateLayerDatabaseObserver(
+ database: database,
+ fetchRequest: ChannelDTO.channelListFetchRequest(
+ query: query,
+ chatClientConfig: clientConfig
+ ),
+ itemCreator: { try $0.asModel() },
+ itemReuseKeyPaths: (\ChatChannel.cid.rawValue, \ChannelDTO.cid),
+ runtimeSorting: query.runtimeSortingValues
+ )
+ }
}
}
+
+protocol ChannelListLinking: Sendable {
+ func start(with eventNotificationCenter: EventNotificationCenter)
+}
diff --git a/Sources/StreamChat/StateLayer/ChannelListState.swift b/Sources/StreamChat/StateLayer/ChannelListState.swift
index 5d1dd4e917d..d0cd41c859e 100644
--- a/Sources/StreamChat/StateLayer/ChannelListState.swift
+++ b/Sources/StreamChat/StateLayer/ChannelListState.swift
@@ -8,7 +8,10 @@ import Foundation
/// Represents a list of channels matching to the specified query.
@MainActor public final class ChannelListState: ObservableObject {
private let observer: Observer
-
+ private var handlers: Observer.Handlers {
+ .init(channelsDidChange: { [weak self] in self?.channels = $0 })
+ }
+
init(
query: ChannelListQuery,
dynamicFilter: (@Sendable (ChatChannel) -> Bool)?,
@@ -18,6 +21,7 @@ import Foundation
eventNotificationCenter: EventNotificationCenter,
channelWatcherHandler: ChannelWatcherHandling
) {
+ let query = channelListUpdater.loadPredefinedFilter(for: query) ?? query
self.query = query
observer = Observer(
query: query,
@@ -28,14 +32,20 @@ import Foundation
eventNotificationCenter: eventNotificationCenter,
channelWatcherHandler: channelWatcherHandler
)
- channels = observer.start(
- with: .init(channelsDidChange: { [weak self] in self?.channels = $0 })
- )
+ channels = observer.start(with: handlers)
}
/// The query used for filtering the list of channels.
- public let query: ChannelListQuery
-
+ public internal(set) var query: ChannelListQuery
+
+ /// A Boolean value that returns whether pagination is finished.
+ var hasLoadedAllPreviousChannels = false
+
/// An array of channels for the specified ``ChannelListQuery``.
@Published public internal(set) var channels: [ChatChannel] = []
+
+ func setQuery(_ query: ChannelListQuery) {
+ self.query = query
+ channels = observer.reload(with: query)
+ }
}
diff --git a/Sources/StreamChat/StateLayer/ChatClient+Factory.swift b/Sources/StreamChat/StateLayer/ChatClient+Factory.swift
index 9fb528d4a9c..b0156e3732c 100644
--- a/Sources/StreamChat/StateLayer/ChatClient+Factory.swift
+++ b/Sources/StreamChat/StateLayer/ChatClient+Factory.swift
@@ -51,6 +51,21 @@ extension ChatClient {
) -> ChannelList {
ChannelList(query: query, dynamicFilter: dynamicFilter, client: self)
}
+
+ /// Creates an instance of ``ChannelList`` which represents an array of channels matching to the specified group.
+ ///
+ /// - Important: The initial state for the group must be fetched with ``ChatClient/queryGroupedChannels(groups:limit:presence:watch:)``
+ /// which does a batch fetch for all the groups.
+ public func makeChannelList(with groupKey: String) -> ChannelList {
+ let channelList = ChannelList(
+ query: .init(groupKey: groupKey),
+ dynamicFilter: nil,
+ client: self
+ )
+ // Start tracking immediately, because the first page is meant to be fetched with queryGroupedChannels
+ syncRepository.startTrackingChannelList(channelList)
+ return channelList
+ }
}
// MARK: - Factory Methods for Creating Chats
diff --git a/Sources/StreamChat/Utils/Dictionary+Extensions.swift b/Sources/StreamChat/Utils/Dictionary+Extensions.swift
index d811d65dad8..45b3636c764 100644
--- a/Sources/StreamChat/Utils/Dictionary+Extensions.swift
+++ b/Sources/StreamChat/Utils/Dictionary+Extensions.swift
@@ -20,3 +20,13 @@ extension Dictionary {
return result
}
}
+
+extension Dictionary where Key == String {
+ /// A deterministic `key=value` representation with keys sorted ascending, joined by `,`.
+ /// Useful for building stable hash inputs from unordered dictionaries.
+ var sortedDescription: String {
+ sorted { $0.key < $1.key }
+ .map { "\($0.key)=\($0.value)" }
+ .joined(separator: ",")
+ }
+}
diff --git a/Sources/StreamChat/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware.swift b/Sources/StreamChat/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware.swift
index 61e739a0681..32cf574b9fa 100644
--- a/Sources/StreamChat/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware.swift
+++ b/Sources/StreamChat/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware.swift
@@ -80,6 +80,9 @@ struct ChannelReadUpdaterMiddleware: EventMiddleware {
read.unreadMessageCount = 0
}
+ case let event as ChannelUpdatedEventDTO:
+ adjustUnreadChannelCountsForGroupChange(event: event, session: session)
+
default:
break
}
@@ -87,6 +90,48 @@ struct ChannelReadUpdaterMiddleware: EventMiddleware {
return event
}
+ /// Adjusts the current user's per-group unread-channel counts when a `ChannelUpdatedEvent`
+ /// moves a channel between groups.
+ ///
+ /// Earlier middlewares in the chain (`EventDataProcessorMiddleware`) have already overwritten
+ /// `channelDTO.extraData` with the new payload, so the old group can no longer be read off the
+ /// channel directly. We recover it from `channelDTO.queries` — `ChannelListLinker` re-links
+ /// the channel into the new group's query *after* the middleware chain finishes, so at this
+ /// point the channel is still linked to whichever grouped query represented its previous group.
+ ///
+ /// The `"all"` bucket is intentionally skipped on both sides — its count is driven by the
+ /// server's `unread_channels` field, not by per-channel deltas.
+ ///
+ /// Channel updated events are not carrying unread group counts.
+ private func adjustUnreadChannelCountsForGroupChange(
+ event: ChannelUpdatedEventDTO,
+ session: DatabaseSession
+ ) {
+ guard let channelDTO = session.channel(cid: event.channel.cid) else { return }
+ guard let knownGroupKeys = session.currentUser?.unreadChannelCountsByGroup?.keys,
+ !knownGroupKeys.isEmpty else { return }
+
+ let groupedFilterHashes = channelDTO.queries
+ .map(\.filterHash)
+ .filter { knownGroupKeys.contains($0) }
+ guard !groupedFilterHashes.isEmpty else { return }
+
+ let oldGroup = groupedFilterHashes.first { $0 != GroupedChannelKey.all }
+ let newGroup = event.channel.extraData[GroupedChannelKey.group]?.stringValue?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased()
+
+ guard oldGroup != newGroup else { return }
+ guard channelDTO.currentUserUnreadMessagesCount > 0 else { return }
+
+ if let oldGroup, oldGroup != GroupedChannelKey.all {
+ session.adjustUnreadChannelCount(forGroup: oldGroup, by: -1)
+ }
+ if let newGroup, !newGroup.isEmpty, newGroup != GroupedChannelKey.all {
+ session.adjustUnreadChannelCount(forGroup: newGroup, by: 1)
+ }
+ }
+
private func isThreadReadEvent(eventPayload: EventPayload) -> Bool {
eventPayload.threadDetails != nil || eventPayload.threadPartial != nil
}
diff --git a/Sources/StreamChat/WebSocketClient/Events/ChannelEvents.swift b/Sources/StreamChat/WebSocketClient/Events/ChannelEvents.swift
index 255c9f067b3..b7db8132978 100644
--- a/Sources/StreamChat/WebSocketClient/Events/ChannelEvents.swift
+++ b/Sources/StreamChat/WebSocketClient/Events/ChannelEvents.swift
@@ -107,7 +107,7 @@ final class ChannelDeletedEventDTO: EventDTO {
}
/// Triggered when a channel is truncated.
-public final class ChannelTruncatedEvent: ChannelSpecificEvent {
+public final class ChannelTruncatedEvent: ChannelSpecificEvent, HasUnreadChannelCountsByGroup {
/// 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) {
+ /// Unread channel counts keyed by the backend-provided group identifier.
+ public let unreadChannelCountsByGroup: [String: Int]?
+
+ init(
+ channel: ChatChannel,
+ user: ChatUser?,
+ message: ChatMessage?,
+ createdAt: Date,
+ unreadChannelCountsByGroup: [String: Int]? = nil
+ ) {
self.channel = channel
self.user = user
self.message = message
self.createdAt = createdAt
+ self.unreadChannelCountsByGroup = unreadChannelCountsByGroup
}
}
@@ -156,7 +166,8 @@ final class ChannelTruncatedEventDTO: EventDTO {
channel: channelDTO.asModel(),
user: userDTO?.asModel(),
message: messageDTO?.asModel(),
- createdAt: createdAt
+ createdAt: createdAt,
+ unreadChannelCountsByGroup: session.currentUser?.unreadChannelCountsByGroup
)
}
}
diff --git a/Sources/StreamChat/WebSocketClient/Events/Event.swift b/Sources/StreamChat/WebSocketClient/Events/Event.swift
index 98b94a98be8..c31b37dc2b3 100644
--- a/Sources/StreamChat/WebSocketClient/Events/Event.swift
+++ b/Sources/StreamChat/WebSocketClient/Events/Event.swift
@@ -39,6 +39,12 @@ public protocol HasUnreadCount: Event {
var unreadCount: UnreadCount? { get }
}
+/// A protocol for events that carry unread channel counts keyed by group.
+public protocol HasUnreadChannelCountsByGroup: Event {
+ /// Unread channel counts keyed by the backend-provided group identifier.
+ var unreadChannelCountsByGroup: [String: Int]? { get }
+}
+
/// A protocol for any `MemberEvent` where it has a `member`, and `channel` payload.
public protocol MemberEvent: Event {
var cid: ChannelId { get }
diff --git a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift
index 004cfb8315a..7b8d4580719 100644
--- a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift
+++ b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift
@@ -36,6 +36,7 @@ final class EventPayload: Decodable, Sendable {
case lastDeliveredAt = "last_delivered_at"
case lastDeliveredMessageId = "last_delivered_message_id"
case unreadMessagesCount = "unread_messages"
+ case unreadChannelCountsByGroup = "grouped_unread_channels"
case shadow
case thread
case vote = "poll_vote"
@@ -76,6 +77,7 @@ final class EventPayload: Decodable, Sendable {
let lastDeliveredAt: Date?
let lastDeliveredMessageId: MessageId?
let unreadMessagesCount: Int?
+ let unreadChannelCountsByGroup: [String: Int]?
let poll: PollPayload?
let vote: PollVotePayload?
@@ -105,6 +107,7 @@ final class EventPayload: Decodable, Sendable {
reaction: MessageReactionPayload? = nil,
watcherCount: Int? = nil,
unreadCount: UnreadCountPayload? = nil,
+ unreadChannelCountsByGroup: [String: Int]? = nil,
createdAt: Date? = nil,
isChannelHistoryCleared: Bool? = nil,
banReason: String? = nil,
@@ -155,6 +158,7 @@ final class EventPayload: Decodable, Sendable {
self.lastReadAt = lastReadAt
self.lastReadMessageId = lastReadMessageId
self.unreadMessagesCount = unreadMessagesCount
+ self.unreadChannelCountsByGroup = unreadChannelCountsByGroup
self.threadPartial = threadPartial
self.threadDetails = threadDetails
self.poll = poll
@@ -199,6 +203,7 @@ final class EventPayload: Decodable, Sendable {
lastReadAt = try container.decodeIfPresent(Date.self, forKey: .lastReadAt)
lastReadMessageId = try container.decodeIfPresent(MessageId.self, forKey: .lastReadMessageId)
unreadMessagesCount = try container.decodeIfPresent(Int.self, forKey: .unreadMessagesCount)
+ unreadChannelCountsByGroup = try container.decodeIfPresent([String: Int].self, forKey: .unreadChannelCountsByGroup)
threadDetails = container.decodeAsResultIfPresent(ThreadDetailsPayload.self, forKey: .thread)
threadPartial = container.decodeAsResultIfPresent(ThreadPartialPayload.self, forKey: .thread)
vote = try container.decodeIfPresent(PollVotePayload.self, forKey: .vote)
@@ -244,6 +249,7 @@ private extension PartialKeyPath where Root == EventPayload {
case \EventPayload.reaction: return "reaction"
case \EventPayload.watcherCount: return "watcherCount"
case \EventPayload.unreadCount: return "unreadCount"
+ case \EventPayload.unreadChannelCountsByGroup: return "unreadChannelCountsByGroup"
case \EventPayload.createdAt: return "createdAt"
case \EventPayload.isChannelHistoryCleared: return "isChannelHistoryCleared"
case \EventPayload.banReason: return "banReason"
diff --git a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift
index 10d4ebfd7df..f8012c8cada 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, HasUnreadChannelCountsByGroup {
/// 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?
+ /// Unread channel counts keyed by the backend-provided group identifier.
+ public let unreadChannelCountsByGroup: [String: Int]?
+
init(
user: ChatUser,
message: ChatMessage,
channel: ChatChannel,
createdAt: Date,
watcherCount: Int?,
- unreadCount: UnreadCount?
+ unreadCount: UnreadCount?,
+ unreadChannelCountsByGroup: [String: Int]? = 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.unreadChannelCountsByGroup = unreadChannelCountsByGroup
}
}
@@ -77,7 +82,8 @@ final class MessageNewEventDTO: EventDTO {
channel: channelDTO.asModel(),
createdAt: createdAt,
watcherCount: watcherCount,
- unreadCount: UnreadCount(currentUserDTO: currentUser)
+ unreadCount: UnreadCount(currentUserDTO: currentUser),
+ unreadChannelCountsByGroup: currentUser.unreadChannelCountsByGroup
)
}
}
diff --git a/Sources/StreamChat/WebSocketClient/Events/NotificationEvents.swift b/Sources/StreamChat/WebSocketClient/Events/NotificationEvents.swift
index 6200017f22c..28c99e2c2db 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, HasUnreadChannelCountsByGroup {
/// 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?) {
+ /// Unread channel counts keyed by the backend-provided group identifier.
+ public let unreadChannelCountsByGroup: [String: Int]?
+
+ init(
+ channel: ChatChannel,
+ message: ChatMessage,
+ createdAt: Date,
+ unreadCount: UnreadCount?,
+ unreadChannelCountsByGroup: [String: Int]? = nil
+ ) {
self.channel = channel
self.message = message
self.createdAt = createdAt
self.unreadCount = unreadCount
+ self.unreadChannelCountsByGroup = unreadChannelCountsByGroup
}
}
@@ -55,7 +65,8 @@ final class NotificationMessageNewEventDTO: EventDTO {
channel: channelDTO.asModel(),
message: messageDTO.asModel(),
createdAt: createdAt,
- unreadCount: UnreadCount(currentUserDTO: currentUser)
+ unreadCount: UnreadCount(currentUserDTO: currentUser),
+ unreadChannelCountsByGroup: currentUser.unreadChannelCountsByGroup
)
}
}
@@ -104,7 +115,7 @@ final class NotificationMarkAllReadEventDTO: EventDTO {
}
/// Triggered when a channel the current user is member of is marked as read.
-public final class NotificationMarkReadEvent: ChannelSpecificEvent, HasUnreadCount {
+public final class NotificationMarkReadEvent: ChannelSpecificEvent, HasUnreadCount, HasUnreadChannelCountsByGroup {
/// 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?
+ /// Unread channel counts keyed by the backend-provided group identifier.
+ public let unreadChannelCountsByGroup: [String: Int]?
+
/// 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?,
+ unreadChannelCountsByGroup: [String: Int]? = nil,
+ lastReadMessageId: MessageId?,
+ createdAt: Date
+ ) {
self.user = user
self.cid = cid
self.unreadCount = unreadCount
+ self.unreadChannelCountsByGroup = unreadChannelCountsByGroup
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, HasUnreadChannelCountsByGroup {
/// 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
+ /// Unread channel counts keyed by the backend-provided group identifier.
+ public let unreadChannelCountsByGroup: [String: Int]?
+
/// 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,
+ unreadChannelCountsByGroup: [String: Int]? = 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.unreadChannelCountsByGroup = unreadChannelCountsByGroup
self.unreadMessagesCount = unreadMessagesCount
}
}
@@ -192,6 +228,7 @@ final class NotificationMarkReadEventDTO: EventDTO {
user: userDTO.asModel(),
cid: cid,
unreadCount: UnreadCount(currentUserDTO: currentUser),
+ unreadChannelCountsByGroup: currentUser.unreadChannelCountsByGroup,
lastReadMessageId: lastReadMessageId,
createdAt: createdAt
)
@@ -233,6 +270,7 @@ final class NotificationMarkUnreadEventDTO: EventDTO {
lastReadMessageId: lastReadMessageId,
lastReadAt: lastReadAt,
unreadCount: UnreadCount(currentUserDTO: currentUser),
+ unreadChannelCountsByGroup: currentUser.unreadChannelCountsByGroup,
unreadMessagesCount: unreadMessagesCount
)
}
@@ -290,7 +328,12 @@ public final class NotificationAddedToChannelEvent: ChannelSpecificEvent, HasUnr
/// The event timestamp.
public let createdAt: Date
- init(channel: ChatChannel, unreadCount: UnreadCount?, member: ChatChannelMember, createdAt: Date) {
+ init(
+ channel: ChatChannel,
+ unreadCount: UnreadCount?,
+ member: ChatChannelMember,
+ createdAt: Date
+ ) {
self.channel = channel
self.unreadCount = unreadCount
self.member = member
@@ -586,7 +629,7 @@ final class NotificationInviteRejectedEventDTO: EventDTO {
}
/// Triggered when a channel is deleted, this event is delivered to all channel members
-public final class NotificationChannelDeletedEvent: ChannelSpecificEvent {
+public final class NotificationChannelDeletedEvent: ChannelSpecificEvent, HasUnreadChannelCountsByGroup {
/// The cid of the deleted channel
public let cid: ChannelId
@@ -596,10 +639,19 @@ public final class NotificationChannelDeletedEvent: ChannelSpecificEvent {
/// The event timestamp.
public let createdAt: Date
- init(cid: ChannelId, channel: ChatChannel, createdAt: Date) {
+ /// Unread channel counts keyed by the backend-provided group identifier.
+ public let unreadChannelCountsByGroup: [String: Int]?
+
+ init(
+ cid: ChannelId,
+ channel: ChatChannel,
+ createdAt: Date,
+ unreadChannelCountsByGroup: [String: Int]? = nil
+ ) {
self.cid = cid
self.channel = channel
self.createdAt = createdAt
+ self.unreadChannelCountsByGroup = unreadChannelCountsByGroup
}
}
@@ -621,7 +673,8 @@ final class NotificationChannelDeletedEventDTO: EventDTO {
return try? NotificationChannelDeletedEvent(
cid: cid,
channel: channelDTO.asModel(),
- createdAt: createdAt
+ createdAt: createdAt,
+ unreadChannelCountsByGroup: session.currentUser?.unreadChannelCountsByGroup
)
}
}
diff --git a/Sources/StreamChat/Workers/ChannelListLinker.swift b/Sources/StreamChat/Workers/ChannelListLinker.swift
index ae98d9959c3..3dbedc56b17 100644
--- a/Sources/StreamChat/Workers/ChannelListLinker.swift
+++ b/Sources/StreamChat/Workers/ChannelListLinker.swift
@@ -11,7 +11,7 @@ import Foundation
/// 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.
-final class ChannelListLinker: Sendable {
+final class ChannelListLinker: Sendable, ChannelListLinking {
private let clientConfig: ChatClientConfig
private let databaseContainer: DatabaseContainer
private nonisolated(unsafe) var eventObservers = [EventObserver]()
diff --git a/Sources/StreamChat/Workers/ChannelListUpdater.swift b/Sources/StreamChat/Workers/ChannelListUpdater.swift
index 28dd407556a..6e678adce58 100644
--- a/Sources/StreamChat/Workers/ChannelListUpdater.swift
+++ b/Sources/StreamChat/Workers/ChannelListUpdater.swift
@@ -4,6 +4,18 @@
import CoreData
+struct ChannelListUpdateResult: Sendable {
+ let channels: [ChatChannel]
+ let updatedQuery: ChannelListQuery?
+}
+
+extension ChannelListUpdateResult {
+ /// Convenience for tests that don't simulate predefined-filter resolution.
+ init(channels: [ChatChannel]) {
+ self.init(channels: channels, updatedQuery: nil)
+ }
+}
+
/// Makes a channels query call to the backend and updates the local storage with the results.
class ChannelListUpdater: Worker, @unchecked Sendable {
/// Makes a channels query call to the backend and updates the local storage with the results.
@@ -14,7 +26,7 @@ class ChannelListUpdater: Worker, @unchecked Sendable {
///
func update(
channelListQuery: ChannelListQuery,
- completion: (@Sendable (Result<[ChatChannel], Error>) -> Void)? = nil
+ completion: (@Sendable (Result) -> Void)? = nil
) {
fetch(channelListQuery: channelListQuery) { [weak self] in
switch $0 {
@@ -23,8 +35,7 @@ class ChannelListUpdater: Worker, @unchecked Sendable {
var initialActions: (@Sendable (DatabaseSession) -> Void)?
if isInitialFetch {
initialActions = { session in
- let filterHash = channelListQuery.filter.filterHash
- guard let queryDTO = session.channelListQuery(filterHash: filterHash) else { return }
+ guard let queryDTO = session.channelListQuery(channelListQuery) else { return }
queryDTO.channels.removeAll()
}
}
@@ -41,6 +52,16 @@ class ChannelListUpdater: Worker, @unchecked Sendable {
}
}
+ /// See `ChannelDatabaseSession.loadPredefinedFilter(for:)` for return semantics.
+ func loadPredefinedFilter(for query: ChannelListQuery) -> ChannelListQuery? {
+ guard let predefinedFilter = query.predefinedFilter, !predefinedFilter.isEmpty else { return nil }
+ do {
+ return try database.readAndWait { $0.loadPredefinedFilter(for: query) }
+ } catch {
+ return nil
+ }
+ }
+
func refreshLoadedChannels(for query: ChannelListQuery, channelCount: Int, completion: @escaping @Sendable (Result, Error>) -> Void) {
guard channelCount > 0 else {
completion(.success(Set()))
@@ -80,10 +101,10 @@ class ChannelListUpdater: Worker, @unchecked Sendable {
query: nextQuery,
completion: { [weak self] writeResult in
switch writeResult {
- case .success(let writtenChannels):
+ case .success(let writeResult):
self?.refreshLoadedChannels(
for: Array(remaining),
- refreshedChannelIds: refreshedChannelIds.union(writtenChannels.map(\.cid)),
+ refreshedChannelIds: refreshedChannelIds.union(writeResult.channels.map(\.cid)),
completion: completion
)
case .failure(let error):
@@ -155,7 +176,7 @@ class ChannelListUpdater: Worker, @unchecked Sendable {
}
}
- /// Unlinks a channel to the given query.
+ /// Unlinks a channel from the given query.
func unlink(channel: ChatChannel, with query: ChannelListQuery, completion: (@Sendable (Error?) -> Void)? = nil) {
database.write { session in
guard let (channelDTO, queryDTO) = session.getChannelWithQuery(cid: channel.cid, query: query) else {
@@ -166,11 +187,109 @@ class ChannelListUpdater: Worker, @unchecked Sendable {
completion?(error)
}
}
+
+ /// The persisted pagination state for a grouped query: the next-page cursor and the
+ /// `watch` / `presence` flags that the original `queryGroupedChannels` call used.
+ ///
+ /// The `watch` and `presence` fields are `nil` when no DTO exists for the group yet — i.e.
+ /// `queryGroupedChannels` has not been called for this `groupKey` in this session. Callers
+ /// can use `state.watch != nil` as the sentinel for "has the group been initialized?".
+ ///
+ /// `ChannelList` (pagination) and `SyncRepository` (reconnect refetch) read this to reuse
+ /// the original flags instead of hardcoding `false`; `ChatClient`'s grouped factory uses
+ /// the nil-ness of `watch` to decide whether to auto-register the new list for sync tracking.
+ struct GroupedQueryPaginationState: Sendable {
+ let next: String?
+ let watch: Bool?
+ let presence: Bool?
+
+ static let empty = GroupedQueryPaginationState(next: nil, watch: nil, presence: nil)
+ }
+
+ func paginationState(for groupKey: String) async throws -> GroupedQueryPaginationState {
+ try await database.read { session in
+ guard let dto = session.channelListQuery(ChannelListQuery(groupKey: groupKey)) else {
+ return GroupedQueryPaginationState.empty
+ }
+ return GroupedQueryPaginationState(next: dto.next, watch: dto.watch, presence: dto.presence)
+ }
+ }
+
+ /// Queries grouped channel groups for the app.
+ func queryGroupedChannels(
+ groups: [String: GroupedQueryChannelsRequestGroup]?,
+ limit: Int?,
+ watch: Bool,
+ presence: Bool,
+ completion: @escaping @Sendable (Result<[ChannelGroup], Error>) -> Void
+ ) {
+ let request = GroupedQueryChannelsRequestBody(
+ limit: groups == nil ? limit : nil,
+ groups: groups,
+ watch: watch,
+ presence: presence
+ )
+ let endpoint: Endpoint = .groupedChannels(request: request)
+ apiClient.request(endpoint: endpoint) { [database] result in
+ switch result {
+ case let .failure(error):
+ completion(.failure(error))
+ case let .success(payload):
+ database.write(converting: { session in
+ let unreadChannelCountsByGroup = payload.groups.mapValues(\.unreadChannels)
+ try session.mergeCurrentUserUnreadChannelCountsByGroup(unreadChannelCountsByGroup)
+ var channelGroups: [ChannelGroup] = []
+ for (groupKey, groupPayload) in payload.groups {
+ let queryDTO = session.saveQuery(query: ChannelListQuery(groupKey: groupKey))
+ let wasFreshFetch = request.groups?[groupKey]?.next == nil
+ if wasFreshFetch {
+ queryDTO.channels.removeAll()
+ }
+ queryDTO.next = groupPayload.next
+ queryDTO.watch = watch
+ queryDTO.presence = presence
+ let channels: [ChatChannel] = groupPayload.channels.compactMapLoggingError { channelPayload in
+ let dto = try session.saveChannel(payload: channelPayload)
+ queryDTO.channels.insert(dto)
+ return try dto.asModel()
+ }
+ channelGroups.append(ChannelGroup(
+ groupKey: groupKey,
+ channels: channels,
+ unreadChannels: groupPayload.unreadChannels,
+ next: groupPayload.next
+ ))
+ }
+ return channelGroups
+ }, completion: { result in
+ completion(result)
+ })
+ }
+ }
+ }
+
+ func queryGroupedChannels(
+ groups: [String: GroupedQueryChannelsRequestGroup]?,
+ limit: Int?,
+ watch: Bool,
+ presence: Bool
+ ) async throws -> [ChannelGroup] {
+ try await withCheckedThrowingContinuation { continuation in
+ queryGroupedChannels(
+ groups: groups,
+ limit: limit,
+ watch: watch,
+ presence: presence
+ ) { result in
+ continuation.resume(with: result)
+ }
+ }
+ }
}
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
}
@@ -189,52 +308,33 @@ private extension ChannelListUpdater {
payload: ChannelListPayload,
query: ChannelListQuery,
initialActions: (@Sendable (DatabaseSession) -> Void)? = nil,
- completion: (@Sendable (Result<[ChatChannel], Error>) -> Void)? = nil
+ completion: (@Sendable (Result) -> Void)? = nil
) {
nonisolated(unsafe) var channels: [ChatChannel] = []
+ nonisolated(unsafe) var resolvedQuery: ChannelListQuery?
database.write { session in
initialActions?(session)
channels = session.saveChannelList(payload: payload, query: query).compactMap { try? $0.asModel() }
+ if let loadedQuery = session.loadPredefinedFilter(for: query), !loadedQuery.isFilterEqual(to: query) {
+ resolvedQuery = loadedQuery
+ }
} completion: { error in
if let error = error {
log.error("Failed to save `ChannelListPayload` to the database. Error: \(error)")
completion?(.failure(error))
} else {
- completion?(.success(channels))
+ completion?(.success(.init(channels: channels, updatedQuery: resolvedQuery)))
}
}
}
}
extension ChannelListUpdater {
- @discardableResult func update(channelListQuery: ChannelListQuery) async throws -> [ChatChannel] {
+ func update(channelListQuery: ChannelListQuery) async throws -> ChannelListUpdateResult {
try await withCheckedThrowingContinuation { continuation in
update(channelListQuery: channelListQuery) { result in
continuation.resume(with: result)
}
}
}
-
- // MARK: -
-
- func loadChannels(query: ChannelListQuery, pagination: Pagination) async throws -> [ChatChannel] {
- try await update(channelListQuery: query.withPagination(pagination))
- }
-
- func loadNextChannels(
- query: ChannelListQuery,
- limit: Int,
- loadedChannelsCount: Int
- ) async throws -> [ChatChannel] {
- let pagination = Pagination(pageSize: limit, offset: loadedChannelsCount)
- return try await update(channelListQuery: query.withPagination(pagination))
- }
-}
-
-private extension ChannelListQuery {
- func withPagination(_ pagination: Pagination) -> Self {
- var query = self
- query.pagination = pagination
- return query
- }
}
diff --git a/Sources/StreamChat/Workers/GroupedChannelListLinker.swift b/Sources/StreamChat/Workers/GroupedChannelListLinker.swift
new file mode 100644
index 00000000000..440cee526d7
--- /dev/null
+++ b/Sources/StreamChat/Workers/GroupedChannelListLinker.swift
@@ -0,0 +1,116 @@
+//
+// Copyright © 2026 Stream.io Inc. All rights reserved.
+//
+
+import Foundation
+
+/// When we receive events, we need to check if a channel should be added or removed from
+/// the current group-based query depending on the following events:
+/// - Channel created: We analyse if the channel should be added to the current group.
+/// - 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 group.
+/// - Channel is updated: We re-evaluate membership — the channel's `"group"` extra-data may have
+/// changed, which can move it into or out of this query.
+///
+/// Membership is decided purely from the channel's `"group"` extra-data value:
+/// - The ``GroupedChannelKey/all`` query is a catch-all: every channel carrying any non-empty group
+/// value links into it.
+/// - Any other `groupKey` links a channel only when its `"group"` value (whitespace-trimmed and
+/// lowercased) equals the query's `groupKey`; otherwise the channel is unlinked.
+final class GroupedChannelListLinker: Sendable, ChannelListLinking {
+ private let channelWatcherHandler: ChannelWatcherHandling
+ private let databaseContainer: DatabaseContainer
+ private nonisolated(unsafe) var eventObservers = [EventObserver]()
+ private let query: ChannelListQuery
+
+ init(
+ query: ChannelListQuery,
+ databaseContainer: DatabaseContainer,
+ channelWatcherHandler: ChannelWatcherHandling
+ ) {
+ self.channelWatcherHandler = channelWatcherHandler
+ self.databaseContainer = databaseContainer
+ self.query = query
+ }
+
+ func start(with nc: EventNotificationCenter) {
+ guard eventObservers.isEmpty else { return }
+ eventObservers = [
+ EventObserver(
+ notificationCenter: nc,
+ transform: { $0 as? NotificationAddedToChannelEvent },
+ callback: { [weak self] event in self?.updateLinking(for: event.channel) }
+ ),
+ EventObserver(
+ notificationCenter: nc,
+ transform: { $0 as? MessageNewEvent },
+ callback: { [weak self] event in self?.updateLinking(for: event.channel) }
+ ),
+ EventObserver(
+ notificationCenter: nc,
+ transform: { $0 as? NotificationMessageNewEvent },
+ callback: { [weak self] event in self?.updateLinking(for: event.channel) }
+ ),
+ EventObserver(
+ notificationCenter: nc,
+ transform: { $0 as? ChannelUpdatedEvent },
+ callback: { [weak self] event in self?.updateLinking(for: event.channel) }
+ ),
+ EventObserver(
+ notificationCenter: nc,
+ transform: { $0 as? ChannelVisibleEvent },
+ callback: { [weak self, databaseContainer] event in
+ let context = databaseContainer.backgroundReadOnlyContext
+ context.perform { [self] in
+ guard let channel = try? context.channel(cid: event.cid)?.asModel() else { return }
+ self?.updateLinking(for: channel)
+ }
+ }
+ )
+ ]
+ }
+
+ /// Links or unlinks the channel for the current grouped query in a single DB write, based on
+ /// whether it should belong to the query and whether it is already part of the list.
+ /// After a fresh link, starts watching the channel when the query was issued with
+ /// ``QueryOptions/watch`` and the channel isn't already linked to another query.
+ private func updateLinking(for channel: ChatChannel) {
+ let shouldBelong = shouldChannelBelongToCurrentQuery(channel)
+ databaseContainer.write(converting: { [query] session -> Bool in
+ guard let (channelDTO, queryDTO) = session.getChannelWithQuery(cid: channel.cid, query: query) else { return false }
+ let isInList = queryDTO.channels.contains(channelDTO)
+ if shouldBelong, !isInList {
+ // Channel isn't linked to any query yet, so it isn't already being watched.
+ let shouldStartWatching = queryDTO.watch && channelDTO.queries.isEmpty
+ queryDTO.channels.insert(channelDTO)
+ return shouldStartWatching
+ } else if !shouldBelong, isInList {
+ queryDTO.channels.remove(channelDTO)
+ }
+ return false
+ }, completion: { [channelWatcherHandler, cid = channel.cid] result in
+ switch result {
+ case .failure(let error):
+ log.error(error)
+ case .success(let shouldStartWatching):
+ guard shouldStartWatching else { return }
+ channelWatcherHandler.attemptToWatch(channelIds: [cid]) { error in
+ guard let error else { return }
+ log.warning("Failed to start watching linked channel: \(cid), error: \(error.localizedDescription)")
+ }
+ }
+ })
+ }
+
+ /// Checks if the given channel should belong to the current query or not.
+ ///
+ /// Empty or missing `"group"` extra-data resolves to `false` — without a filter to fall back to,
+ /// the safest move is to unlink rather than leave stale links behind after a `ChannelUpdatedEvent`.
+ private func shouldChannelBelongToCurrentQuery(_ channel: ChatChannel) -> Bool {
+ let currentGroupKey = channel.extraData[GroupedChannelKey.group]?.stringValue?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased()
+ guard let currentGroupKey, !currentGroupKey.isEmpty else { return false }
+ return query.groupKey == currentGroupKey || query.groupKey == GroupedChannelKey.all
+ }
+}
diff --git a/Sources/StreamChatCommonUI/Info.plist b/Sources/StreamChatCommonUI/Info.plist
index 527d28b1e68..c1d1bf86199 100644
--- a/Sources/StreamChatCommonUI/Info.plist
+++ b/Sources/StreamChatCommonUI/Info.plist
@@ -15,7 +15,7 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 5.4.1
+ 5.5.0
CFBundleVersion
$(CURRENT_PROJECT_VERSION)
diff --git a/Sources/StreamChatUI/Info.plist b/Sources/StreamChatUI/Info.plist
index 527d28b1e68..c1d1bf86199 100644
--- a/Sources/StreamChatUI/Info.plist
+++ b/Sources/StreamChatUI/Info.plist
@@ -15,7 +15,7 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 5.4.1
+ 5.5.0
CFBundleVersion
$(CURRENT_PROJECT_VERSION)
diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj
index 8b08170e929..188bd4e7108 100644
--- a/StreamChat.xcodeproj/project.pbxproj
+++ b/StreamChat.xcodeproj/project.pbxproj
@@ -867,6 +867,7 @@
StreamChatTests/Database/DataStore_Tests.swift,
StreamChatTests/Database/DTOs/AttachmentDTO_Tests.swift,
StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift,
+ StreamChatTests/Database/DTOs/ChannelListQueryDTO_Tests.swift,
StreamChatTests/Database/DTOs/ChannelMemberListQueryDTO_Tests.swift,
StreamChatTests/Database/DTOs/ChannelMuteDTO_Tests.swift,
StreamChatTests/Database/DTOs/ChannelReadDTO_Tests.swift,
@@ -911,6 +912,7 @@
StreamChatTests/Models/Poll_Tests.swift,
StreamChatTests/Models/User_Tests.swift,
StreamChatTests/Query/ChannelListFilterScope_Tests.swift,
+ StreamChatTests/Query/ChannelListQuery_PredefinedFilter_Tests.swift,
StreamChatTests/Query/ChannelListQuery_Tests.swift,
StreamChatTests/Query/ChannelMemberListQuery_Tests.swift,
StreamChatTests/Query/ChannelQuery_Tests.swift,
diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift
index dafedec557d..abd142168fa 100644
--- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift
+++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift
@@ -32,7 +32,7 @@ class ChatChannelListController_Mock: ChatChannelListController, Spy, @unchecked
loadNextChannelsIsCalled = true
}
- override func refreshLoadedChannels(completion: @escaping (Result, any Error>) -> Void) {
+ override func refreshLoadedChannels(completion: @escaping @Sendable (Result, any Error>) -> Void) {
record()
refreshLoadedChannelsResult.map(completion)
}
diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift
index 4fb34bde7aa..98bfab52344 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] {
@@ -58,6 +58,10 @@ class DatabaseSession_Mock: DatabaseSession {
underlyingSession.saveChannelList(payload: payload, query: query)
}
+ func loadPredefinedFilter(for query: ChannelListQuery) -> ChannelListQuery? {
+ underlyingSession.loadPredefinedFilter(for: query)
+ }
+
func saveQuery(query: ReactionListQuery) throws -> ReactionListQueryDTO? {
try underlyingSession.saveQuery(query: query)
}
@@ -111,12 +115,21 @@ 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 mergeCurrentUserUnreadChannelCountsByGroup(_ unreadChannelCountsByGroup: [String: Int]) throws {
+ try throwErrorIfNeeded()
+ try underlyingSession.mergeCurrentUserUnreadChannelCountsByGroup(unreadChannelCountsByGroup)
+ }
+
+ func adjustUnreadChannelCount(forGroup groupKey: String, by delta: Int) {
+ underlyingSession.adjustUnreadChannelCount(forGroup: groupKey, by: delta)
}
func deleteDevice(id: DeviceId) {
@@ -375,12 +388,12 @@ class DatabaseSession_Mock: DatabaseSession {
underlyingSession.loadChannelReads(for: userId)
}
- func saveQuery(query: ChannelListQuery) -> ChannelListQueryDTO {
- underlyingSession.saveQuery(query: query)
+ func saveQuery(query: ChannelListQuery, predefinedFilter: PredefinedFilterPayload?) -> ChannelListQueryDTO {
+ underlyingSession.saveQuery(query: query, predefinedFilter: predefinedFilter)
}
- 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/Mocks/StreamChat/State/ChannelList_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/State/ChannelList_Mock.swift
index 64203337cfd..60865d04b7e 100644
--- a/TestTools/StreamChatTestTools/Mocks/StreamChat/State/ChannelList_Mock.swift
+++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/State/ChannelList_Mock.swift
@@ -33,11 +33,16 @@ public class ChannelList_Mock: ChannelList, @unchecked Sendable {
@MainActor public func simulate(channels: [ChatChannel]) async throws {
state.channels = channels
}
+
+ @Atomic public var refreshLoadedChannelsCallCount = 0
+ @Atomic public var refreshLoadedChannelsResult: Result, Error> = .success([])
+ override public func refreshLoadedChannels() async throws -> Set {
+ _refreshLoadedChannelsCallCount.mutate { $0 += 1 }
+ return try refreshLoadedChannelsResult.get()
+ }
- public var loadNextChannelsIsCalled = false
override public func loadMoreChannels(limit: Int? = nil) async throws -> [ChatChannel] {
- loadNextChannelsIsCalled = true
- return await MainActor.run {
+ await MainActor.run {
Array(state.channels)
}
}
diff --git a/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift b/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift
index 932fa0b9835..8e97cd46200 100644
--- a/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift
+++ b/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift
@@ -10,25 +10,32 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable
let spyState = SpyState()
@Atomic var update_queries: [ChannelListQuery] = []
- @Atomic var update_completion: ((Result<[ChatChannel], Error>) -> Void)?
+ @Atomic var update_completion: (@Sendable (Result<[ChatChannel], Error>) -> Void)?
@Atomic var update_completion_result: Result<[ChatChannel], Error>?
@Atomic var fetch_queries: [ChannelListQuery] = []
- @Atomic var fetch_completion: ((Result) -> Void)?
+ @Atomic var fetch_completion: (@Sendable (Result) -> Void)?
@Atomic var refreshLoadedChannelsResult: Result, Error>?
- @Atomic var markAllRead_completion: ((Error?) -> Void)?
+ @Atomic var queryGroupedChannels_callCount = 0
+ @Atomic var queryGroupedChannels_groups: [[String: GroupedQueryChannelsRequestGroup]?] = []
+ @Atomic var queryGroupedChannels_limits: [Int?] = []
+ @Atomic var queryGroupedChannels_watchValues: [Bool] = []
+ @Atomic var queryGroupedChannels_presenceValues: [Bool] = []
+ @Atomic var queryGroupedChannels_result: Result<[ChannelGroup], Error>?
- var startWatchingChannels_callCount = 0
+ @Atomic var markAllRead_completion: (@Sendable (Error?) -> Void)?
+
+ @Atomic var startWatchingChannels_callCount = 0
@Atomic var startWatchingChannels_cids: [ChannelId] = []
- @Atomic var startWatchingChannels_completion: ((Error?) -> Void)?
- var startWatchingChannels_completion_success = false
+ @Atomic var startWatchingChannels_completion: (@Sendable (Error?) -> Void)?
+ @Atomic var startWatchingChannels_completion_success = false
- var link_callCount = 0
- var link_completion: ((Error?) -> Void)?
+ @Atomic var link_callCount = 0
+ @Atomic var link_completion: (@Sendable (Error?) -> Void)?
- var unlink_callCount = 0
+ @Atomic var unlink_callCount = 0
func cleanUp() {
update_queries.removeAll()
@@ -38,6 +45,12 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable
fetch_queries.removeAll()
fetch_completion = nil
+ queryGroupedChannels_callCount = 0
+ queryGroupedChannels_groups.removeAll()
+ queryGroupedChannels_limits.removeAll()
+ queryGroupedChannels_watchValues.removeAll()
+ queryGroupedChannels_presenceValues.removeAll()
+ queryGroupedChannels_result = nil
markAllRead_completion = nil
startWatchingChannels_cids.removeAll()
@@ -47,20 +60,27 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable
override func update(
channelListQuery: ChannelListQuery,
- completion: ((Result<[ChatChannel], Error>) -> Void)? = nil
+ completion: (@Sendable (Result) -> Void)? = nil
) {
_update_queries.mutate { $0.append(channelListQuery) }
- update_completion = completion
- update_completion_result?.invoke(with: completion)
+ let resolvedQuery = loadPredefinedFilter(for: channelListQuery)
+ update_completion = { result in
+ let changedQuery: ChannelListQuery? = {
+ guard let resolvedQuery, !resolvedQuery.isFilterEqual(to: channelListQuery) else { return nil }
+ return resolvedQuery
+ }()
+ completion?(result.map { ChannelListUpdateResult(channels: $0, updatedQuery: changedQuery) })
+ }
+ update_completion_result?.invoke(with: update_completion)
}
- override func markAllRead(completion: ((Error?) -> Void)? = nil) {
+ override func markAllRead(completion: (@Sendable (Error?) -> Void)? = nil) {
markAllRead_completion = completion
}
override func fetch(
channelListQuery: ChannelListQuery,
- completion: @escaping (Result) -> Void
+ completion: @escaping @Sendable (Result) -> Void
) {
_fetch_queries.mutate { $0.append(channelListQuery) }
fetch_completion = completion
@@ -69,31 +89,58 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable
override func refreshLoadedChannels(
for query: ChannelListQuery,
channelCount: Int,
- completion: @escaping (Result, any Error>) -> Void
+ completion: @escaping @Sendable (Result, any Error>) -> Void
) {
record()
refreshLoadedChannelsResult?.invoke(with: completion)
}
+ override func queryGroupedChannels(
+ groups: [String: GroupedQueryChannelsRequestGroup]?,
+ limit: Int?,
+ watch: Bool,
+ presence: Bool,
+ completion: @escaping @Sendable (Result<[ChannelGroup], Error>) -> Void
+ ) {
+ _queryGroupedChannels_callCount.mutate { $0 += 1 }
+ _queryGroupedChannels_groups.mutate { $0.append(groups) }
+ _queryGroupedChannels_limits.mutate { $0.append(limit) }
+ _queryGroupedChannels_watchValues.mutate { $0.append(watch) }
+ _queryGroupedChannels_presenceValues.mutate { $0.append(presence) }
+ if let result = queryGroupedChannels_result {
+ DispatchQueue.main.async {
+ completion(result)
+ }
+ } else {
+ super.queryGroupedChannels(
+ groups: groups,
+ limit: limit,
+ watch: watch,
+ presence: presence,
+ completion: completion
+ )
+ }
+ }
+
override func link(
channel: ChatChannel,
with query: ChannelListQuery,
- completion: ((Error?) -> Void)? = nil
+ completion: (@Sendable (Error?) -> Void)? = nil
) {
- link_callCount += 1
+ _link_callCount.mutate { $0 += 1 }
link_completion = completion
}
override func unlink(
channel: ChatChannel,
with query: ChannelListQuery,
- completion: ((Error?) -> Void)? = nil
+ completion: (@Sendable (Error?) -> Void)? = nil
) {
- unlink_callCount += 1
+ _unlink_callCount.mutate { $0 += 1 }
}
- override func startWatchingChannels(withIds ids: [ChannelId], completion: ((Error?) -> Void)?) {
- startWatchingChannels_callCount += 1
+ override func startWatchingChannels(withIds ids: [ChannelId], completion: (@Sendable (Error?) -> Void)?) {
+ _startWatchingChannels_callCount.mutate { $0 += 1 }
startWatchingChannels_cids = ids
if startWatchingChannels_completion_success {
completion?(nil)
diff --git a/TestTools/StreamChatTestTools/TestData/FilterTestScope.swift b/TestTools/StreamChatTestTools/TestData/FilterTestScope.swift
index 934b6a5645c..dbd8ad59da4 100644
--- a/TestTools/StreamChatTestTools/TestData/FilterTestScope.swift
+++ b/TestTools/StreamChatTestTools/TestData/FilterTestScope.swift
@@ -58,12 +58,15 @@ struct FilterCodingTestPair {
.lessOrEqualDouble(),
.inArrayInt(),
.inArrayDouble(),
+ .inArrayString(),
+ .equalArrayInt(),
.query(),
.autocomplete(),
.existsTrue(),
.notExists(),
.containsAndEqual(),
- .greaterOrLess()
+ .greaterOrLess(),
+ .nor()
]
}
@@ -164,6 +167,27 @@ extension FilterCodingTestPair {
return FilterCodingTestPair(json: json, filter: filter)
}
+ static func inArrayString() -> FilterCodingTestPair {
+ let json = #"{"test_key_ArrayString":{"$in":["a","b"]}}"#
+ let filter: Filter = .in(.testKeyArrayString, values: ["a", "b"])
+ return FilterCodingTestPair(json: json, filter: filter)
+ }
+
+ static func equalArrayInt() -> FilterCodingTestPair {
+ let json = #"{"test_key_ArrayInt":{"$eq":[1,2]}}"#
+ let filter: Filter = .equal(.testKeyArrayInt, values: [1, 2])
+ return FilterCodingTestPair(json: json, filter: filter)
+ }
+
+ static func nor() -> FilterCodingTestPair {
+ let json = #"{"$nor":[{"test_key_Int":{"$eq":1}},{"test_key_Bool":{"$eq":true}}]}"#
+ let filter: Filter = .nor([
+ .equal(.testKeyInt, to: 1),
+ .equal(.testKeyBool, to: true)
+ ])
+ return FilterCodingTestPair(json: json, filter: filter)
+ }
+
private static func existsFilter(exists: Bool) -> FilterCodingTestPair {
let json = "{\"test_key_Int\":{\"$exists\":\(exists)}}"
let filter: Filter = .exists(.testKeyInt, exists: exists)
diff --git a/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift
index 63e07cf2c08..ca7a6c4d3ba 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, groups: nil, watch: true, presence: false), true),
+ (.init(limit: 10, groups: nil, watch: false, presence: true), true),
+ (.init(limit: 10, groups: nil, watch: true, presence: true), true),
+ (.init(limit: 10, groups: nil, watch: false, presence: false), false)
+ ]
+
+ for (request, requiresConnectionId) in testCases {
+ let expectedEndpoint = Endpoint(
+ path: .groupedChannels,
+ method: .post,
+ queryItems: nil,
+ requiresConnectionId: requiresConnectionId,
+ body: request
+ )
+
+ let endpoint: Endpoint = .groupedChannels(request: request)
+
+ XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint))
+ XCTAssertEqual("channels/grouped", endpoint.path.value)
+ }
+ }
+
func test_channel_buildsCorrectly() {
let cid = ChannelId(type: .livestream, id: "qwerty")
diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift
index b1522c11a00..ef4a2174a44 100644
--- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift
+++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift
@@ -62,6 +62,225 @@ final class ChannelListPayload_Tests: XCTestCase {
XCTAssertEqual(payload.channels.count, 2)
}
+ func test_channelListPayload_decodesPredefinedFilter() throws {
+ let json = """
+ {
+ "channels": [],
+ "predefined_filter": {
+ "name": "user_per_channel_type_channels",
+ "filter": { "members": { "$in": ["r2-d2"] }, "type": "messaging" },
+ "sort": [
+ { "direction": -1, "field": "last_message_at", "type": "string" },
+ { "direction": -1, "field": "created_at", "type": "string" }
+ ]
+ }
+ }
+ """.data(using: .utf8)!
+
+ let payload = try JSONDecoder.default.decode(ChannelListPayload.self, from: json)
+
+ let predefined = try XCTUnwrap(payload.predefinedFilter)
+ XCTAssertEqual(predefined.name, "user_per_channel_type_channels")
+ XCTAssertEqual(predefined.filter["type"], .string("messaging"))
+ XCTAssertEqual(predefined.filter["members"], .dictionary(["$in": .array([.string("r2-d2")])]))
+ XCTAssertEqual(predefined.sort.count, 2)
+ XCTAssertEqual(predefined.sort.first?["field"], .string("last_message_at"))
+ XCTAssertEqual(predefined.sort.first?["direction"], .number(-1))
+ }
+
+ func test_channelListPayload_predefinedFilter_isNilWhenAbsent() throws {
+ let json = "{ \"channels\": [] }".data(using: .utf8)!
+
+ let payload = try JSONDecoder.default.decode(ChannelListPayload.self, from: json)
+
+ XCTAssertNil(payload.predefinedFilter)
+ }
+
+ func test_groupedQueryChannelsPayload_decodesGroupsMap() throws {
+ let channelId = ChannelId(type: .messaging, id: "bucket-channel")
+ let json = """
+ {
+ "groups": {
+ "all": {
+ "channels": [
+ {
+ "channel": {
+ "cid": "\(channelId.rawValue)",
+ "id": "\(channelId.id)",
+ "type": "\(channelId.type.rawValue)",
+ "name": "Support",
+ "image": "https://getstream.imgix.net/images/random_svg/stream_logo.svg",
+ "created_at": "2024-01-01T00:00:00.000Z",
+ "updated_at": "2024-01-02T00:00:00.000Z",
+ "frozen": false,
+ "disabled": false,
+ "config": {
+ "typing_events": true,
+ "read_events": true,
+ "connect_events": true,
+ "search": true,
+ "reactions": true,
+ "replies": true,
+ "quotes": true,
+ "uploads": true,
+ "url_enrichment": true,
+ "mutes": true,
+ "message_retention": "infinite",
+ "max_message_length": 5000,
+ "created_at": "2024-01-01T00:00:00.000Z",
+ "updated_at": "2024-01-02T00:00:00.000Z",
+ "commands": []
+ },
+ "own_capabilities": [],
+ "member_count": 0
+ },
+ "members": [],
+ "messages": [],
+ "pinned_messages": [],
+ "watchers": [],
+ "watcher_count": 0,
+ "read": []
+ }
+ ],
+ "unread_channels": 1
+ }
+ },
+ "duration": "12ms"
+ }
+ """.data(using: .utf8)!
+
+ let payload = try JSONDecoder.default.decode(GroupedQueryChannelsPayload.self, from: json)
+
+ XCTAssertEqual(payload.groups.keys.sorted(), ["all"])
+ XCTAssertEqual(payload.groups["all"]?.channels.map(\.channel.cid), [channelId])
+ XCTAssertEqual(payload.groups["all"]?.unreadChannels, 1)
+ }
+
+ func test_groupedQueryChannelsPayload_decodesNextAndPrevCursors() throws {
+ let json = """
+ {
+ "groups": {
+ "current": {
+ "channels": [],
+ "unread_channels": 0,
+ "next": "current-next-cursor",
+ "prev": "current-prev-cursor"
+ }
+ },
+ "duration": "5ms"
+ }
+ """.data(using: .utf8)!
+
+ let payload = try JSONDecoder.default.decode(GroupedQueryChannelsPayload.self, from: json)
+
+ XCTAssertEqual("current-next-cursor", payload.groups["current"]?.next)
+ XCTAssertEqual("current-prev-cursor", payload.groups["current"]?.prev)
+ }
+
+ func test_groupedQueryChannelsPayload_cursorsAreNilWhenMissing() throws {
+ let json = """
+ {
+ "groups": {
+ "all": { "channels": [], "unread_channels": 0 }
+ },
+ "duration": "5ms"
+ }
+ """.data(using: .utf8)!
+
+ let payload = try JSONDecoder.default.decode(GroupedQueryChannelsPayload.self, from: json)
+
+ XCTAssertNil(payload.groups["all"]?.next)
+ XCTAssertNil(payload.groups["all"]?.prev)
+ }
+
+ func test_groupedQueryChannelsRequestBody_allGroups_encodesWithoutGroupsKey() throws {
+ let body = GroupedQueryChannelsRequestBody(limit: 10, groups: nil, watch: true, presence: false)
+
+ let encoded = try JSONEncoder.stream.encode(body)
+ let json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any]
+
+ XCTAssertEqual(10, json?["limit"] as? Int)
+ XCTAssertEqual(true, json?["watch"] as? Bool)
+ XCTAssertEqual(false, json?["presence"] as? Bool)
+ XCTAssertNil(json?["groups"])
+ }
+
+ func test_groupedQueryChannelsRequestBody_paginatedGroup_encodesWithGroupsKeyAndCursor() throws {
+ let body = GroupedQueryChannelsRequestBody(
+ limit: nil,
+ groups: ["old": .init(limit: 5, next: "old-cursor")],
+ watch: false,
+ presence: true
+ )
+
+ let encoded = try JSONEncoder.stream.encode(body)
+ let json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any]
+ let groups = json?["groups"] as? [String: [String: Any]]
+
+ XCTAssertNil(json?["limit"])
+ XCTAssertEqual(false, json?["watch"] as? Bool)
+ XCTAssertEqual(true, json?["presence"] as? Bool)
+ XCTAssertEqual(5, groups?["old"]?["limit"] as? Int)
+ XCTAssertEqual("old-cursor", groups?["old"]?["next"] as? String)
+ XCTAssertNil(groups?["old"]?["prev"])
+ }
+
+ func test_groupedQueryChannelsPayload_defaultsUnreadCountersWhenMissing() throws {
+ let channelId = ChannelId(type: .messaging, id: "bucket-channel")
+ let json = """
+ {
+ "groups": {
+ "expired": {
+ "channels": [
+ {
+ "channel": {
+ "cid": "\(channelId.rawValue)",
+ "id": "\(channelId.id)",
+ "type": "\(channelId.type.rawValue)",
+ "created_at": "2024-01-01T00:00:00.000Z",
+ "updated_at": "2024-01-02T00:00:00.000Z",
+ "frozen": false,
+ "disabled": false,
+ "config": {
+ "typing_events": true,
+ "read_events": true,
+ "connect_events": true,
+ "search": true,
+ "reactions": true,
+ "replies": true,
+ "quotes": true,
+ "uploads": true,
+ "url_enrichment": true,
+ "mutes": true,
+ "message_retention": "infinite",
+ "max_message_length": 5000,
+ "created_at": "2024-01-01T00:00:00.000Z",
+ "updated_at": "2024-01-02T00:00:00.000Z",
+ "commands": []
+ },
+ "own_capabilities": [],
+ "member_count": 0
+ },
+ "members": [],
+ "messages": [],
+ "pinned_messages": [],
+ "watchers": [],
+ "watcher_count": 0,
+ "read": []
+ }
+ ]
+ }
+ },
+ "duration": "12ms"
+ }
+ """.data(using: .utf8)!
+
+ let payload = try JSONDecoder.default.decode(GroupedQueryChannelsPayload.self, from: json)
+
+ XCTAssertEqual(payload.groups["expired"]?.channels.map(\.channel.cid), [channelId])
+ XCTAssertEqual(payload.groups["expired"]?.unreadChannels, 0)
+ }
+
func saveChannelListPayload(_ payload: ChannelListPayload, database: DatabaseContainer_Spy, timeout: TimeInterval = 20) {
let writeCompleted = expectation(description: "DB write complete")
database.write({ session in
diff --git a/Tests/StreamChatTests/ChatClient_Tests.swift b/Tests/StreamChatTests/ChatClient_Tests.swift
index e1fcd45dfd0..1e1cdbce820 100644
--- a/Tests/StreamChatTests/ChatClient_Tests.swift
+++ b/Tests/StreamChatTests/ChatClient_Tests.swift
@@ -262,6 +262,75 @@ final class ChatClient_Tests: XCTestCase {
XCTAssert(testEnv.apiClient?.init_requestEncoder is RequestEncoder_Spy)
}
+ func test_queryGroupedChannels_withSpecificGroups_returnsMappedChannelGroups() async throws {
+ let client = ChatClient.mock(config: inMemoryStorageConfig)
+ try client.databaseContainer.createCurrentUser()
+ let firstCid = ChannelId.unique
+ let secondCid = ChannelId.unique
+
+ let payload = GroupedQueryChannelsPayload(
+ groups: [
+ "new": .init(
+ channels: [dummyPayload(with: firstCid)],
+ unreadChannels: 1
+ ),
+ "current": .init(
+ channels: [dummyPayload(with: secondCid)],
+ unreadChannels: 2
+ )
+ ]
+ )
+ client.mockAPIClient.test_mockResponseResult(.success(payload))
+
+ let groupedChannels = try await client.queryGroupedChannels(groups: ["new", "current"], limit: 4, presence: false, watch: true)
+
+ XCTAssertEqual(groupedChannels.map(\.groupKey).sorted(), ["current", "new"])
+ }
+
+ func test_queryGroupedChannels_withSpecificGroups_sendsPerGroupBody() async throws {
+ let client = ChatClient.mock(config: inMemoryStorageConfig)
+ try client.databaseContainer.createCurrentUser()
+
+ let payload = GroupedQueryChannelsPayload(
+ groups: [
+ "new": .init(channels: [], unreadChannels: 0),
+ "current": .init(channels: [], unreadChannels: 0)
+ ]
+ )
+ client.mockAPIClient.test_mockResponseResult(.success(payload))
+
+ _ = try await client.queryGroupedChannels(groups: ["new", "current"], limit: 5)
+
+ let body = try XCTUnwrap(client.mockAPIClient.request_endpoint?.bodyAsDictionary())
+ XCTAssertNil(body["limit"], "Top-level limit must be omitted when groups are specified")
+ let groups = try XCTUnwrap(body["groups"] as? [String: [String: Any]])
+ XCTAssertEqual(["current", "new"], groups.keys.sorted())
+ XCTAssertEqual(5, groups["new"]?["limit"] as? Int)
+ XCTAssertEqual(5, groups["current"]?["limit"] as? Int)
+ XCTAssertNil(groups["new"]?["next"])
+ XCTAssertNil(groups["current"]?["next"])
+ }
+
+ func test_queryGroupedChannels_withEmptyGroups_sendsFetchAllBody() async throws {
+ let client = ChatClient.mock(config: inMemoryStorageConfig)
+ try client.databaseContainer.createCurrentUser()
+
+ client.mockAPIClient.test_mockResponseResult(.success(GroupedQueryChannelsPayload(groups: [:])))
+ _ = try await client.queryGroupedChannels(groups: [], limit: 4)
+
+ let body = try XCTUnwrap(client.mockAPIClient.request_endpoint?.bodyAsDictionary())
+ XCTAssertEqual(4, body["limit"] as? Int)
+ XCTAssertNil(body["groups"])
+ }
+
+ func test_makeChannelList_withGroupKey_startsTrackingChannelList() {
+ let client = ChatClient.mock(config: inMemoryStorageConfig)
+
+ let channelList = client.makeChannelList(with: "all")
+
+ XCTAssertTrue(client.syncRepository.activeChannelLists.contains(channelList))
+ }
+
func test_disconnect_flushesRequestsQueue() throws {
// Create a chat client
let client = ChatClient(
diff --git a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift
index d12b2280edd..9a599bb9619 100644
--- a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift
+++ b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift
@@ -245,6 +245,96 @@ final class ChannelListController_Tests: XCTestCase {
XCTAssertEqual(controller.channels.map(\.cid), [channelId])
}
+ // MARK: - Predefined filter resolution
+
+ func test_synchronize_predefinedFilterQuery_createsObserverWithCachedResolvedFilterAndSort() throws {
+ // GIVEN: a controller built with a predefined query (placeholder filter + empty sort)
+ let predefinedQuery = ChannelListQuery(
+ predefinedFilter: "user_per_channel_type_channels",
+ filterValues: ["user_id": "r2-d2"]
+ )
+ query = predefinedQuery
+ controller = ChatChannelListController(query: predefinedQuery, client: client, environment: env.environment)
+
+ // AND: the DTO carries server-resolved filter/sort JSON (different from placeholder)
+ let resolvedFilterJSON = #"{"type":"messaging"}"#.data(using: .utf8)!
+ let resolvedSortJSON = #"[{"field":"last_message_at","direction":-1}]"#.data(using: .utf8)!
+ try client.databaseContainer.writeSynchronously { session in
+ let dto = session.saveQuery(query: predefinedQuery)
+ dto.filterJSONData = resolvedFilterJSON
+ dto.sortJSONData = resolvedSortJSON
+ }
+
+ // Snapshot the observer identity after lazy init. The cached predefined filter is applied before observer creation.
+ let observerBefore = ObjectIdentifier(controller.channelListObserver)
+ XCTAssertEqual(controller.query.filter.key, "type")
+ XCTAssertEqual(controller.query.filter.value as? String, "messaging")
+ XCTAssertEqual(controller.query.sort.count, 1)
+ XCTAssertEqual(controller.query.sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey)
+
+ // WHEN: synchronize completes successfully
+ let exp = expectation(description: "synchronize completes")
+ var receivedError: Error?
+ controller.synchronize { error in
+ receivedError = error
+ exp.fulfill()
+ }
+ env.channelListUpdater?.update_completion?(.success([]))
+ waitForExpectations(timeout: defaultTimeout)
+
+ // THEN: no error, query has resolved values, observer is not rebuilt again for the same effective query
+ XCTAssertNil(receivedError)
+ XCTAssertEqual(controller.query.filter.key, "type")
+ XCTAssertEqual(controller.query.filter.value as? String, "messaging")
+ XCTAssertEqual(controller.query.sort.count, 1)
+ XCTAssertEqual(controller.query.sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey)
+ XCTAssertEqual(controller.query.sort.first?.direction, -1)
+ XCTAssertEqual(ObjectIdentifier(controller.channelListObserver), observerBefore)
+ }
+
+ func test_synchronize_predefinedFilterQuery_whenNetworkFails_keepsCachedResolvedFilterAndReportsError() throws {
+ let predefinedQuery = ChannelListQuery(
+ predefinedFilter: "user_per_channel_type_channels",
+ filterValues: ["user_id": "r2-d2"]
+ )
+ query = predefinedQuery
+ controller = ChatChannelListController(query: predefinedQuery, client: client, environment: env.environment)
+ try client.databaseContainer.writeSynchronously { session in
+ let dto = session.saveQuery(query: predefinedQuery)
+ dto.filterJSONData = #"{"type":"messaging"}"#.data(using: .utf8)!
+ dto.sortJSONData = #"[{"field":"last_message_at","direction":-1}]"#.data(using: .utf8)!
+ }
+ _ = controller.channelListObserver
+
+ let error = TestError()
+ let exp = expectation(description: "synchronize completes")
+ var receivedError: Error?
+ controller.synchronize { error in
+ receivedError = error
+ exp.fulfill()
+ }
+ env.channelListUpdater?.update_completion?(.failure(error))
+ waitForExpectations(timeout: defaultTimeout)
+
+ XCTAssertEqual(receivedError as? TestError, error)
+ XCTAssertEqual(controller.query.filter.key, "type")
+ XCTAssertEqual(controller.query.sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey)
+ }
+
+ func test_synchronize_nonPredefinedQuery_doesNotRebuildObserver() {
+ // GIVEN: a controller built with a non-predefined query (default in setUp)
+ let observerBefore = ObjectIdentifier(controller.channelListObserver)
+
+ // WHEN: synchronize completes successfully
+ let exp = expectation(description: "synchronize completes")
+ controller.synchronize { _ in exp.fulfill() }
+ env.channelListUpdater?.update_completion?(.success([]))
+ waitForExpectations(timeout: defaultTimeout)
+
+ // THEN: the observer instance is unchanged
+ XCTAssertEqual(ObjectIdentifier(controller.channelListObserver), observerBefore)
+ }
+
// MARK: - Change propagation tests
func test_changesInTheDatabase_arePropagated() throws {
@@ -806,6 +896,32 @@ final class ChannelListController_Tests: XCTestCase {
AssertAsync.canBeReleased(&weakController)
}
+ func test_loadNextChannels_predefinedFilterQuery_appliesResolvedFilterAndRebuildsObserver() throws {
+ let predefinedQuery = ChannelListQuery(
+ predefinedFilter: "user_per_channel_type_channels",
+ filterValues: ["user_id": "r2-d2"]
+ )
+ query = predefinedQuery
+ controller = ChatChannelListController(query: predefinedQuery, client: client, environment: env.environment)
+ try client.databaseContainer.writeSynchronously { session in
+ let dto = session.saveQuery(query: predefinedQuery)
+ dto.filterJSONData = #"{"type":"messaging"}"#.data(using: .utf8)!
+ dto.sortJSONData = #"[{"field":"last_message_at","direction":-1}]"#.data(using: .utf8)!
+ }
+
+ let exp = expectation(description: "load next completes")
+ controller.loadNextChannels { error in
+ XCTAssertNil(error)
+ exp.fulfill()
+ }
+ env.channelListUpdater?.update_completion?(.success([]))
+ waitForExpectations(timeout: defaultTimeout)
+
+ XCTAssertEqual(controller.query.filter.key, "type")
+ XCTAssertEqual(controller.query.filter.value as? String, "messaging")
+ XCTAssertEqual(controller.query.sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey)
+ }
+
// MARK: - Refresh Loaded Channels
func test_refreshLoadedChannels_whenSucceedsThenControllerSucceeds() {
diff --git a/Tests/StreamChatTests/Database/DTOs/ChannelListQueryDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/ChannelListQueryDTO_Tests.swift
new file mode 100644
index 00000000000..42f0278d5ba
--- /dev/null
+++ b/Tests/StreamChatTests/Database/DTOs/ChannelListQueryDTO_Tests.swift
@@ -0,0 +1,189 @@
+//
+// Copyright © 2026 Stream.io Inc. All rights reserved.
+//
+
+import CoreData
+@testable import StreamChat
+@testable import StreamChatTestTools
+import XCTest
+
+final class ChannelListQueryDTO_Tests: XCTestCase {
+ var database: DatabaseContainer!
+
+ override func setUp() {
+ super.setUp()
+ database = DatabaseContainer_Spy()
+ }
+
+ override func tearDown() {
+ AssertAsync.canBeReleased(&database)
+ database = nil
+ super.tearDown()
+ }
+
+ func test_saveQuery_withoutPredefinedFilter_writesFilterFromQueryAndLeavesSortNil() throws {
+ let cid = ChannelId.unique
+ let query = ChannelListQuery(filter: .equal(.cid, to: cid))
+
+ try database.writeSynchronously { session in
+ _ = (session as! NSManagedObjectContext).saveQuery(query: query)
+ }
+
+ try database.readSynchronously { session in
+ let dto = try XCTUnwrap((session as! NSManagedObjectContext).channelListQuery(query))
+ let savedFilter = try JSONDecoder.default.decode(Filter.self, from: dto.filterJSONData)
+ XCTAssertEqual(savedFilter.filterHash, query.filter.filterHash)
+ XCTAssertNil(dto.sortJSONData)
+ }
+ }
+
+ func test_saveQuery_withPredefinedFilter_writesFilterAndSortFromResponse() throws {
+ let query = ChannelListQuery(
+ predefinedFilter: "user_per_channel_type_channels",
+ filterValues: ["user_id": "r2-d2"]
+ )
+ let predefinedFilter = PredefinedFilterPayload(
+ name: "user_per_channel_type_channels",
+ filter: [
+ "members": .dictionary(["$in": .array([.string("r2-d2")])]),
+ "type": .string("messaging")
+ ],
+ sort: [
+ ["direction": .number(-1), "field": .string("last_message_at"), "type": .string("string")],
+ ["direction": .number(-1), "field": .string("created_at"), "type": .string("string")]
+ ]
+ )
+
+ try database.writeSynchronously { session in
+ _ = (session as! NSManagedObjectContext).saveQuery(query: query, predefinedFilter: predefinedFilter)
+ }
+
+ try database.readSynchronously { session in
+ let dto = try XCTUnwrap((session as! NSManagedObjectContext).channelListQuery(query))
+ let savedFilter = try JSONDecoder.default.decode([String: RawJSON].self, from: dto.filterJSONData)
+ XCTAssertEqual(savedFilter, predefinedFilter.filter)
+
+ let sortData = try XCTUnwrap(dto.sortJSONData)
+ let savedSort = try JSONDecoder.default.decode([[String: RawJSON]].self, from: sortData)
+ XCTAssertEqual(savedSort, predefinedFilter.sort)
+ }
+ }
+
+ func test_saveQuery_withPredefinedFilter_overwritesExistingDTO() throws {
+ let query = ChannelListQuery(
+ predefinedFilter: "user_per_channel_type_channels",
+ filterValues: ["user_id": "r2-d2"]
+ )
+ let first = PredefinedFilterPayload(
+ name: "user_per_channel_type_channels",
+ filter: ["type": .string("messaging")],
+ sort: [["field": .string("last_message_at"), "direction": .number(-1)]]
+ )
+ let second = PredefinedFilterPayload(
+ name: "user_per_channel_type_channels",
+ filter: ["type": .string("livestream")],
+ sort: [["field": .string("created_at"), "direction": .number(1)]]
+ )
+
+ try database.writeSynchronously { session in
+ _ = (session as! NSManagedObjectContext).saveQuery(query: query, predefinedFilter: first)
+ }
+ try database.writeSynchronously { session in
+ _ = (session as! NSManagedObjectContext).saveQuery(query: query, predefinedFilter: second)
+ }
+
+ try database.readSynchronously { session in
+ let dto = try XCTUnwrap((session as! NSManagedObjectContext).channelListQuery(query))
+ let savedFilter = try JSONDecoder.default.decode([String: RawJSON].self, from: dto.filterJSONData)
+ XCTAssertEqual(savedFilter, second.filter)
+
+ let sortData = try XCTUnwrap(dto.sortJSONData)
+ let savedSort = try JSONDecoder.default.decode([[String: RawJSON]].self, from: sortData)
+ XCTAssertEqual(savedSort, second.sort)
+ }
+ }
+
+ func test_loadPredefinedFilter_persistedDTO_returnsQueryWithDecodedFilterAndSort() throws {
+ let query = ChannelListQuery(
+ predefinedFilter: "user_per_channel_type_channels",
+ filterValues: ["user_id": "r2-d2"]
+ )
+ let payload = PredefinedFilterPayload(
+ name: "user_per_channel_type_channels",
+ filter: ["type": .string("messaging")],
+ sort: [["field": .string("last_message_at"), "direction": .number(-1)]]
+ )
+ try database.writeSynchronously { session in
+ _ = session.saveQuery(query: query, predefinedFilter: payload)
+ }
+
+ let updated = try XCTUnwrap(database.readAndWait { session in
+ session.loadPredefinedFilter(for: query)
+ })
+
+ XCTAssertEqual(updated.filter.key, "type")
+ XCTAssertEqual(updated.filter.value as? String, "messaging")
+ XCTAssertEqual(updated.filter.keyPathString, #keyPath(ChannelDTO.typeRawValue))
+ XCTAssertEqual(updated.sort.count, 1)
+ XCTAssertEqual(updated.sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey)
+ XCTAssertEqual(updated.sort.first?.direction, -1)
+ }
+
+ func test_loadPredefinedFilter_noPersistedDTO_returnsNil() throws {
+ let query = ChannelListQuery(predefinedFilter: "user_per_channel_type_channels")
+
+ let updated = try database.readAndWait { session in
+ session.loadPredefinedFilter(for: query)
+ }
+
+ XCTAssertNil(updated)
+ }
+
+ func test_loadPredefinedFilter_persistedDTOWithNilSort_returnsQueryWithDecodedFilterAndEmptySort() throws {
+ let query = ChannelListQuery(predefinedFilter: "user_per_channel_type_channels")
+ let payload = PredefinedFilterPayload(
+ name: "user_per_channel_type_channels",
+ filter: ["type": .string("messaging")],
+ sort: []
+ )
+ try database.writeSynchronously { session in
+ let dto = session.saveQuery(query: query, predefinedFilter: payload)
+ dto.sortJSONData = nil
+ }
+
+ let updated = try XCTUnwrap(database.readAndWait { session in
+ session.loadPredefinedFilter(for: query)
+ })
+
+ XCTAssertEqual(updated.filter.key, "type")
+ XCTAssertEqual(updated.filter.value as? String, "messaging")
+ XCTAssertTrue(updated.sort.isEmpty)
+ }
+
+ func test_loadPredefinedFilter_invalidPersistedJSON_returnsQueryUnchanged() throws {
+ let query = ChannelListQuery(predefinedFilter: "user_per_channel_type_channels")
+ try database.writeSynchronously { session in
+ let dto = session.saveQuery(query: query, predefinedFilter: nil)
+ dto.filterJSONData = Data("not-json".utf8)
+ dto.sortJSONData = Data("not-json".utf8)
+ }
+
+ let updated = try XCTUnwrap(database.readAndWait { session in
+ session.loadPredefinedFilter(for: query)
+ })
+
+ XCTAssertEqual(updated.filter.filterHash, query.filter.filterHash)
+ XCTAssertEqual(updated.sort.map(\.description), query.sort.map(\.description))
+ XCTAssertEqual(updated.predefinedFilter, query.predefinedFilter)
+ }
+
+ func test_loadPredefinedFilter_nonPredefinedQuery_returnsNil() throws {
+ let query = ChannelListQuery(filter: .equal(.cid, to: .unique))
+
+ let updated = try database.readAndWait { session in
+ session.loadPredefinedFilter(for: query)
+ }
+
+ XCTAssertNil(updated)
+ }
+}
diff --git a/Tests/StreamChatTests/Database/DTOs/CurrentUserDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/CurrentUserDTO_Tests.swift
index c3a8a9ebb08..c8e7a452442 100644
--- a/Tests/StreamChatTests/Database/DTOs/CurrentUserDTO_Tests.swift
+++ b/Tests/StreamChatTests/Database/DTOs/CurrentUserDTO_Tests.swift
@@ -182,6 +182,38 @@ final class CurrentUserModelDTO_Tests: XCTestCase {
XCTAssertEqual(currentUser?.unreadCount.threads, 3)
}
+ func test_mergeCurrentUserUnreadChannelCountsByGroup_storesAndLoadsFromDB() throws {
+ let payload = CurrentUserPayload.dummy(userPayload: .dummy(userId: .unique, role: .admin))
+ let unreadChannelCountsByGroup: [String: Int] = [
+ "direct": 2,
+ "support": 5
+ ]
+
+ try database.writeSynchronously { session in
+ try session.saveCurrentUser(payload: payload)
+ try session.mergeCurrentUserUnreadChannelCountsByGroup(unreadChannelCountsByGroup)
+ }
+
+ let loadedCurrentUser = try database.readSynchronously { try XCTUnwrap($0.currentUser?.asModel()) }
+ XCTAssertEqual(loadedCurrentUser.unreadChannelCountsByGroup, unreadChannelCountsByGroup)
+ }
+
+ func test_mergeCurrentUserUnreadChannelCountsByGroup_mergesIntoExistingValues() throws {
+ let payload = CurrentUserPayload.dummy(userPayload: .dummy(userId: .unique, role: .admin))
+
+ try database.writeSynchronously { session in
+ try session.saveCurrentUser(payload: payload)
+ try session.mergeCurrentUserUnreadChannelCountsByGroup(["direct": 2, "support": 5])
+ try session.mergeCurrentUserUnreadChannelCountsByGroup(["direct": 10, "billing": 1])
+ }
+
+ let loadedCurrentUser = try database.readSynchronously { try XCTUnwrap($0.currentUser?.asModel()) }
+ XCTAssertEqual(
+ loadedCurrentUser.unreadChannelCountsByGroup,
+ ["direct": 10, "support": 5, "billing": 1]
+ )
+ }
+
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 8db9ecece6d..f04fe86cd35 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_eventPayloadUnreadChannelCountsByGroup_isSavedToDatabase() throws {
+ let currentUserPayload = CurrentUserPayload.dummy(userPayload: .dummy(userId: .unique, role: .admin))
+ let unreadChannelCountsByGroup: [String: Int] = [
+ "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),
+ unreadChannelCountsByGroup: unreadChannelCountsByGroup,
+ createdAt: .unique
+ ))
+ }
+
+ XCTAssertEqual(database.viewContext.currentUser?.unreadChannelCountsByGroup, unreadChannelCountsByGroup)
+ }
+
func test_deleteMessage() throws {
let channelId: ChannelId = .unique
let messageId: MessageId = .unique
@@ -269,6 +291,27 @@ final class DatabaseSession_Tests: XCTestCase {
XCTAssertEqual(Int64(eventPayload.unreadCount!.threads!), currentUser?.unreadThreadsCount)
}
+ func test_saveEvent_mergesUnreadChannelCountsByGroupIntoExistingValues() throws {
+ let userId = UserId.unique
+ try database.writeSynchronously { session in
+ try session.saveCurrentUser(payload: .dummy(userId: userId, role: .user))
+ try session.mergeCurrentUserUnreadChannelCountsByGroup(["all": 5, "old": 1])
+ }
+
+ let eventPayload = EventPayload(
+ eventType: .messageNew,
+ cid: .unique,
+ unreadChannelCountsByGroup: ["all": 7, "new": 2]
+ )
+
+ try database.writeSynchronously { session in
+ try session.saveEvent(payload: eventPayload)
+ }
+
+ let counters = try database.readSynchronously { $0.currentUser?.unreadChannelCountsByGroup ?? [:] }
+ XCTAssertEqual(["all": 7, "new": 2, "old": 1], counters)
+ }
+
func test_saveCurrentUserUnreadCount_failsIfThereIsNoCurrentUser() throws {
func saveUnreadCountWithoutUser() throws {
try database.writeSynchronously {
@@ -654,6 +697,106 @@ final class DatabaseSession_Tests: XCTestCase {
XCTAssertEqual(channelModel.latestMessages.first?.id, newMessage.id)
}
+ func test_saveEvent_whenMessageNewEventComesWithoutChannelMessageCount_keepsExistingChannelMessageCount() throws {
+ let existingMessage: MessagePayload = .dummy(
+ messageId: .unique,
+ authorUserId: .unique
+ )
+ let channel: ChannelPayload = .dummy(
+ channel: .dummy(messageCount: 1),
+ messages: [existingMessage]
+ )
+
+ try database.writeSynchronously { session in
+ try session.saveChannel(payload: channel)
+ }
+
+ let newMessage: MessagePayload = .dummy(
+ messageId: .unique,
+ authorUserId: .unique,
+ createdAt: existingMessage.createdAt.addingTimeInterval(10)
+ )
+
+ let messageNewEvent = EventPayload(
+ eventType: .messageNew,
+ cid: channel.channel.cid,
+ channel: channel.channel,
+ message: newMessage
+ )
+
+ try database.writeSynchronously { session in
+ try session.saveEvent(payload: messageNewEvent)
+ }
+
+ let channelDTO = try XCTUnwrap(database.viewContext.channel(cid: channel.channel.cid))
+ XCTAssertEqual(channelDTO.messageCount?.intValue, 1)
+ }
+
+ func test_saveEvent_whenNotificationMessageNewEventComesWithoutChannelMessageCount_keepsExistingChannelMessageCount() throws {
+ let existingMessage: MessagePayload = .dummy(
+ messageId: .unique,
+ authorUserId: .unique
+ )
+ let channel: ChannelPayload = .dummy(
+ channel: .dummy(messageCount: 1),
+ messages: [existingMessage]
+ )
+
+ try database.writeSynchronously { session in
+ try session.saveChannel(payload: channel)
+ }
+
+ let newMessage: MessagePayload = .dummy(
+ messageId: .unique,
+ authorUserId: .unique,
+ createdAt: existingMessage.createdAt.addingTimeInterval(10)
+ )
+
+ let messageNewEvent = EventPayload(
+ eventType: .notificationMessageNew,
+ cid: channel.channel.cid,
+ channel: channel.channel,
+ message: newMessage
+ )
+
+ try database.writeSynchronously { session in
+ try session.saveEvent(payload: messageNewEvent)
+ }
+
+ let channelDTO = try XCTUnwrap(database.viewContext.channel(cid: channel.channel.cid))
+ XCTAssertEqual(channelDTO.messageCount?.intValue, 1)
+ }
+
+ func test_saveEvent_whenMessageNewEventComesWithoutChannelMessageCountAndStoredCountIsMissing_keepsMessageCountNil() throws {
+ let channel: ChannelPayload = .dummy(
+ channel: .dummy(messageCount: nil),
+ messages: []
+ )
+
+ try database.writeSynchronously { session in
+ try session.saveChannel(payload: channel)
+ }
+
+ let newMessage: MessagePayload = .dummy(
+ messageId: .unique,
+ authorUserId: .unique
+ )
+
+ let messageNewEvent = EventPayload(
+ eventType: .messageNew,
+ cid: channel.channel.cid,
+ channel: channel.channel,
+ message: newMessage
+ )
+
+ try database.writeSynchronously { session in
+ try session.saveEvent(payload: messageNewEvent)
+ }
+
+ let channelDTO = try XCTUnwrap(database.viewContext.channel(cid: channel.channel.cid))
+ XCTAssertNil(channelDTO.messageCount)
+ }
+
func test_saveEvent_whenMessageDeletedEvent_latestMessagesFirstStillReturnsDeletedMessage() throws {
// GIVEN
let message: MessagePayload = .dummy(
diff --git a/Tests/StreamChatTests/Query/ChannelListQuery_PredefinedFilter_Tests.swift b/Tests/StreamChatTests/Query/ChannelListQuery_PredefinedFilter_Tests.swift
new file mode 100644
index 00000000000..d4f1c57f1ea
--- /dev/null
+++ b/Tests/StreamChatTests/Query/ChannelListQuery_PredefinedFilter_Tests.swift
@@ -0,0 +1,367 @@
+//
+// Copyright © 2026 Stream.io Inc. All rights reserved.
+//
+
+import CoreData
+@testable import StreamChat
+@testable import StreamChatTestTools
+import XCTest
+
+final class ChannelListQuery_PredefinedFilter_Tests: XCTestCase {
+ func test_predefinedFilterKeyMapping_includeEveryHardcodedChannelListFilterKey() {
+ let expectedKeys: Set = [
+ FilterKey.cid.rawValue,
+ FilterKey.id.rawValue,
+ FilterKey.name.rawValue,
+ FilterKey.imageURL.rawValue,
+ FilterKey.type.rawValue,
+ FilterKey.lastMessageAt.rawValue,
+ FilterKey.createdBy.rawValue,
+ FilterKey.createdAt.rawValue,
+ FilterKey.updatedAt.rawValue,
+ FilterKey.deletedAt.rawValue,
+ FilterKey.hidden.rawValue,
+ FilterKey.frozen.rawValue,
+ FilterKey.disabled.rawValue,
+ FilterKey.blocked.rawValue,
+ FilterKey.archived.rawValue,
+ FilterKey.pinned.rawValue,
+ FilterKey.members.rawValue,
+ FilterKey.memberCount.rawValue,
+ FilterKey.team.rawValue,
+ FilterKey.joined.rawValue,
+ FilterKey.muted.rawValue,
+ FilterKey.invite.rawValue,
+ FilterKey.memberName.rawValue,
+ FilterKey.lastUpdatedAt.rawValue,
+ FilterKey.channelRole.rawValue,
+ FilterKey.filterTags.rawValue,
+ FilterKey.hasUnread.rawValue
+ ]
+
+ XCTAssertEqual(Set(ChannelListFilterScope.predefinedFilterKeyMapping.keys), expectedKeys)
+ // Guards against another `FilterKey` scope leaking into the generated mapping.
+ XCTAssertEqual(ChannelListFilterScope.predefinedFilterKeyMapping.count, expectedKeys.count)
+ }
+
+ func test_predefinedFilterSortingKeys_includeEveryHardcodedChannelListSortingKey() {
+ let expectedKeys: Set = [
+ ChannelListSortingKey.default.remoteKey,
+ ChannelListSortingKey.createdAt.remoteKey,
+ ChannelListSortingKey.updatedAt.remoteKey,
+ ChannelListSortingKey.lastMessageAt.remoteKey,
+ ChannelListSortingKey.pinnedAt.remoteKey,
+ ChannelListSortingKey.memberCount.remoteKey,
+ ChannelListSortingKey.cid.remoteKey,
+ ChannelListSortingKey.hasUnread.remoteKey,
+ ChannelListSortingKey.unreadCount.remoteKey
+ ]
+
+ XCTAssertEqual(Set(ChannelListSortingKey.predefinedSortingKeyMapping.keys), expectedKeys)
+ // Guards against another sorting-key model leaking into the generated mapping.
+ XCTAssertEqual(ChannelListSortingKey.predefinedSortingKeyMapping.count, expectedKeys.count)
+ XCTAssertTrue(ChannelListSortingKey.predefinedSortingKeyMapping.values.allSatisfy { $0.localKey != nil })
+ }
+
+ func test_predefinedFilter_fromJSONData_implicitEqual_attachesKeyPathAndValueMapper() throws {
+ let json = #"{"type":"messaging"}"#.data(using: .utf8)!
+
+ let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json))
+
+ XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue)
+ XCTAssertEqual(filter.key, "type")
+ XCTAssertEqual(filter.value as? String, "messaging")
+ XCTAssertEqual(filter.keyPathString, #keyPath(ChannelDTO.typeRawValue))
+ XCTAssertNotNil(filter.valueMapper)
+ XCTAssertNotNil(filter.predicate)
+ }
+
+ func test_predefinedFilter_fromJSONData_inOperator_attachesKeyPath() throws {
+ let json = #"{"members":{"$in":["r2-d2"]}}"#.data(using: .utf8)!
+
+ let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json))
+
+ XCTAssertEqual(filter.operator, FilterOperator.in.rawValue)
+ XCTAssertEqual(filter.key, "members")
+ XCTAssertEqual(filter.value as? [String], ["r2-d2"])
+ XCTAssertEqual(filter.keyPathString, #keyPath(ChannelDTO.members.user.id))
+ }
+
+ func test_predefinedFilter_fromJSONData_collectionKey_preservesCollectionFilterFlag() throws {
+ let json = #"{"member.user.name":{"$autocomplete":"Leia"}}"#.data(using: .utf8)!
+
+ let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json))
+
+ XCTAssertEqual(filter.operator, FilterOperator.autocomplete.rawValue)
+ XCTAssertEqual(filter.key, "member.user.name")
+ XCTAssertEqual(filter.value as? String, "Leia")
+ XCTAssertEqual(filter.keyPathString, #keyPath(ChannelDTO.members.user.name))
+ XCTAssertTrue(filter.isCollectionFilter)
+ }
+
+ func test_predefinedFilter_fromJSONData_groupOperator_enrichesAllChildrenMixedForms() throws {
+ let json = #"{"$and":[{"type":"messaging"},{"members":{"$in":["r2-d2"]}}]}"#.data(using: .utf8)!
+
+ let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json))
+
+ XCTAssertEqual(filter.operator, FilterOperator.and.rawValue)
+ let children = try XCTUnwrap(filter.value as? [Filter])
+ XCTAssertEqual(children.count, 2)
+
+ let typeChild = try XCTUnwrap(children.first { $0.key == "type" })
+ XCTAssertEqual(typeChild.operator, FilterOperator.equal.rawValue)
+ XCTAssertEqual(typeChild.keyPathString, #keyPath(ChannelDTO.typeRawValue))
+
+ let membersChild = try XCTUnwrap(children.first { $0.key == "members" })
+ XCTAssertEqual(membersChild.operator, FilterOperator.in.rawValue)
+ XCTAssertEqual(membersChild.keyPathString, #keyPath(ChannelDTO.members.user.id))
+ }
+
+ func test_predefinedFilter_fromJSONData_multiKeyObject_decodesAsImplicitAnd() throws {
+ let json = #"{"members":{"$in":["r2-d2"]},"type":"messaging"}"#.data(using: .utf8)!
+
+ let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json))
+
+ XCTAssertEqual(filter.operator, FilterOperator.and.rawValue)
+ let children = try XCTUnwrap(filter.value as? [Filter])
+ XCTAssertEqual(children.count, 2)
+ XCTAssertNotNil(children.first { $0.key == "members" })
+ XCTAssertNotNil(children.first { $0.key == "type" })
+ }
+
+ func test_predefinedFilter_fromJSONData_mixedFieldAndGroupOperator_keepsAllKeysAsImplicitAnd() throws {
+ // A field key (`type`) alongside a group-operator key (`$or`) at the same level. The backend
+ // ANDs both, so neither may be dropped: `type == messaging AND (member-of OR frozen)`.
+ let json = #"{"type":"messaging","$or":[{"members":{"$in":["amy"]}},{"frozen":true}]}"#.data(using: .utf8)!
+
+ let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json))
+
+ XCTAssertEqual(filter.operator, FilterOperator.and.rawValue)
+ let children = try XCTUnwrap(filter.value as? [Filter])
+ XCTAssertEqual(children.count, 2)
+
+ // The bare field key is enriched as a leaf.
+ let typeChild = try XCTUnwrap(children.first { $0.key == "type" })
+ XCTAssertEqual(typeChild.operator, FilterOperator.equal.rawValue)
+ XCTAssertEqual(typeChild.keyPathString, #keyPath(ChannelDTO.typeRawValue))
+
+ // The group operator is preserved with its children enriched.
+ let orChild = try XCTUnwrap(children.first { $0.operator == FilterOperator.or.rawValue })
+ let orGrandchildren = try XCTUnwrap(orChild.value as? [Filter])
+ XCTAssertEqual(orGrandchildren.count, 2)
+ XCTAssertEqual(
+ orGrandchildren.first { $0.key == "members" }?.keyPathString,
+ #keyPath(ChannelDTO.members.user.id)
+ )
+
+ // Every condition contributes to the predicate (nothing silently dropped).
+ XCTAssertNotNil(filter.predicate)
+ }
+
+ func test_predefinedFilter_fromJSONData_nullValue_decodesNilTeam() throws {
+ let json = #"{"team":null}"#.data(using: .utf8)!
+
+ let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json))
+
+ XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue)
+ XCTAssertEqual(filter.key, "team")
+ XCTAssertNil(filter.value as? TeamId)
+ XCTAssertEqual(filter.keyPathString, #keyPath(ChannelDTO.team))
+ XCTAssertNotNil(filter.predicate)
+ }
+
+ func test_predefinedFilter_fromJSONData_nonNullTeam_decodesValueAndKeyPath() throws {
+ let json = #"{"team":"red"}"#.data(using: .utf8)!
+
+ let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json))
+
+ XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue)
+ XCTAssertEqual(filter.key, "team")
+ XCTAssertEqual(filter.value as? String, "red")
+ XCTAssertEqual(filter.keyPathString, #keyPath(ChannelDTO.team))
+ XCTAssertNotNil(filter.predicate)
+ }
+
+ func test_predefinedFilter_fromJSONData_nullTeamInsideGroup_decodesNil() throws {
+ let json = #"{"$and":[{"team":null},{"type":"messaging"}]}"#.data(using: .utf8)!
+
+ let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json))
+
+ XCTAssertEqual(filter.operator, FilterOperator.and.rawValue)
+ let children = try XCTUnwrap(filter.value as? [Filter])
+ let teamChild = try XCTUnwrap(children.first { $0.key == "team" })
+ XCTAssertNil(teamChild.value as? TeamId)
+ XCTAssertEqual(teamChild.keyPathString, #keyPath(ChannelDTO.team))
+ XCTAssertNotNil(children.first { $0.key == "type" })
+ }
+
+ func test_predefinedFilter_fromJSONData_nullTeamInMultiKey_keepsBothKeys() throws {
+ let json = #"{"team":null,"type":"messaging"}"#.data(using: .utf8)!
+
+ let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json))
+
+ XCTAssertEqual(filter.operator, FilterOperator.and.rawValue)
+ let children = try XCTUnwrap(filter.value as? [Filter])
+ XCTAssertEqual(children.count, 2)
+ XCTAssertNil(try XCTUnwrap(children.first { $0.key == "team" }).value as? TeamId)
+ XCTAssertNotNil(children.first { $0.key == "type" })
+ }
+
+ func test_predefinedFilter_fromJSONData_unknownKey_passesThrough() throws {
+ let json = #"{"made_up_field":"x"}"#.data(using: .utf8)!
+
+ let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json))
+
+ XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue)
+ XCTAssertEqual(filter.key, "made_up_field")
+ XCTAssertEqual(filter.value as? String, "x")
+ XCTAssertNil(filter.keyPathString)
+ // Without Core Data wiring the leaf produces no local predicate (rather than crashing).
+ XCTAssertNil(filter.valueMapper)
+ XCTAssertNil(filter.predicate)
+ }
+
+ func test_predefinedFilter_fromJSONData_groupWithUnknownKey_dropsUnwiredLeafFromPredicate() throws {
+ let json = #"{"$and":[{"type":"messaging"},{"made_up_field":"x"}]}"#.data(using: .utf8)!
+
+ let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json))
+
+ // The unknown leaf carries no keyPath, so it contributes no predicate; the known leaf still does.
+ let predicate = try XCTUnwrap(filter.predicate)
+ XCTAssertTrue(
+ predicate.predicateFormat.contains("typeRawValue"),
+ "Expected the known `type` leaf to drive the predicate; got: \(predicate.predicateFormat)"
+ )
+ XCTAssertFalse(
+ predicate.predicateFormat.contains("made_up_field"),
+ "Expected the unknown leaf to be dropped from the predicate; got: \(predicate.predicateFormat)"
+ )
+ }
+
+ func test_predefinedFilter_fromJSONData_predicateMapperKey_isPreserved() throws {
+ let json = #"{"archived":true}"#.data(using: .utf8)!
+
+ let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json))
+
+ XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue)
+ XCTAssertEqual(filter.key, "archived")
+ XCTAssertNotNil(filter.predicateMapper)
+
+ let predicate = try XCTUnwrap(filter.predicate)
+ XCTAssertTrue(
+ predicate.predicateFormat.contains("archivedAt"),
+ "Expected predicate to reference archivedAt; got: \(predicate.predicateFormat)"
+ )
+ XCTAssertTrue(
+ predicate.predicateFormat.contains("!= nil"),
+ "Expected archived=true to map to `archivedAt != nil`; got: \(predicate.predicateFormat)"
+ )
+ }
+
+ func test_predefinedFilter_fromJSONData_emptyData_returnsNil() throws {
+ XCTAssertNil(try Filter.predefinedFilter(fromJSONData: Data()))
+ }
+
+ func test_predefinedFilter_fromJSONData_membersInArray_decodesArrayValueAndKeyPath() throws {
+ let json = #"{"members":{"$in":["amy","leia","r2-d2"]}}"#.data(using: .utf8)!
+
+ let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json))
+
+ XCTAssertEqual(filter.operator, FilterOperator.in.rawValue)
+ XCTAssertEqual(filter.key, "members")
+ XCTAssertEqual(filter.value as? [String], ["amy", "leia", "r2-d2"])
+ XCTAssertEqual(filter.keyPathString, #keyPath(ChannelDTO.members.user.id))
+ XCTAssertNotNil(filter.predicate)
+ }
+
+ func test_predefinedFilter_fromJSONData_numericGreaterThan_attachesKeyPath() throws {
+ let json = #"{"member_count":{"$gt":5}}"#.data(using: .utf8)!
+
+ let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json))
+
+ XCTAssertEqual(filter.operator, FilterOperator.greater.rawValue)
+ XCTAssertEqual(filter.key, "member_count")
+ XCTAssertEqual(filter.value as? Int, 5)
+ XCTAssertEqual(filter.keyPathString, #keyPath(ChannelDTO.memberCount))
+ XCTAssertNotNil(filter.predicate)
+ }
+
+ func test_predefinedFilter_fromJSONData_dateValue_roundTripsThroughDecoder() throws {
+ // Build via the DSL + encoder so the ISO8601 string matches CodableHelper's formatter exactly.
+ let date = Date(timeIntervalSince1970: 1_600_000_000)
+ let encoded = try JSONEncoder.default.encode(Filter.less(.createdAt, than: date))
+
+ let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: encoded))
+
+ XCTAssertEqual(filter.operator, FilterOperator.less.rawValue)
+ XCTAssertEqual(filter.key, "created_at")
+ XCTAssertEqual(filter.value as? Date, date)
+ XCTAssertEqual(filter.keyPathString, #keyPath(ChannelDTO.createdAt))
+ XCTAssertNotNil(filter.predicate)
+ }
+
+ func test_predefinedFilter_fromJSONData_predicateMapperKeysInGroup_enrichBoth() throws {
+ let json = #"{"$or":[{"archived":true},{"pinned":true}]}"#.data(using: .utf8)!
+
+ let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json))
+
+ XCTAssertEqual(filter.operator, FilterOperator.or.rawValue)
+ let children = try XCTUnwrap(filter.value as? [Filter])
+ XCTAssertEqual(children.count, 2)
+ XCTAssertNotNil(try XCTUnwrap(children.first { $0.key == "archived" }).predicateMapper)
+ XCTAssertNotNil(try XCTUnwrap(children.first { $0.key == "pinned" }).predicateMapper)
+
+ let predicate = try XCTUnwrap(filter.predicate)
+ XCTAssertTrue(predicate.predicateFormat.contains("archivedAt"), predicate.predicateFormat)
+ XCTAssertTrue(predicate.predicateFormat.contains("pinnedAt"), predicate.predicateFormat)
+ }
+
+ func test_predefinedFilterSort_decodesKnownFields() throws {
+ let json = #"""
+ [
+ {"field": "last_message_at", "direction": -1},
+ {"field": "created_at", "direction": 1}
+ ]
+ """#.data(using: .utf8)!
+
+ let sort = try XCTUnwrap([Sorting].predefinedFilterSort(fromJSONData: json))
+
+ XCTAssertEqual(sort.count, 2)
+ XCTAssertEqual(sort[0].key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey)
+ XCTAssertEqual(sort[0].direction, -1)
+ XCTAssertEqual(sort[1].key.remoteKey, ChannelListSortingKey.createdAt.remoteKey)
+ XCTAssertEqual(sort[1].direction, 1)
+ }
+
+ func test_predefinedFilterSort_dropsUnknownFields() throws {
+ let json = #"""
+ [
+ {"field": "made_up_field", "direction": -1},
+ {"field": "last_message_at", "direction": -1}
+ ]
+ """#.data(using: .utf8)!
+
+ let sort = try XCTUnwrap([Sorting].predefinedFilterSort(fromJSONData: json))
+
+ XCTAssertEqual(sort.count, 1)
+ XCTAssertEqual(sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey)
+ }
+
+ func test_predefinedFilterSort_handlesExtraJSONFields() throws {
+ let json = #"""
+ [
+ {"field": "last_message_at", "direction": -1, "type": "string"}
+ ]
+ """#.data(using: .utf8)!
+
+ let sort = try XCTUnwrap([Sorting].predefinedFilterSort(fromJSONData: json))
+
+ XCTAssertEqual(sort.count, 1)
+ XCTAssertEqual(sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey)
+ XCTAssertEqual(sort.first?.direction, -1)
+ }
+
+ func test_predefinedFilterSort_emptyData_returnsNil() throws {
+ XCTAssertNil(try [Sorting].predefinedFilterSort(fromJSONData: Data()))
+ }
+}
diff --git a/Tests/StreamChatTests/Query/ChannelListQuery_Tests.swift b/Tests/StreamChatTests/Query/ChannelListQuery_Tests.swift
index cd06bed1158..06f81d9801e 100644
--- a/Tests/StreamChatTests/Query/ChannelListQuery_Tests.swift
+++ b/Tests/StreamChatTests/Query/ChannelListQuery_Tests.swift
@@ -69,6 +69,115 @@ final class ChannelListQuery_Tests: XCTestCase {
XCTAssertEqual(runtimeSorting.count, 3)
}
+ func test_channelListQuery_predefinedFilter_encodedCorrectly() throws {
+ let pageSize = Int.channelsPageSize
+ var query = ChannelListQuery(
+ predefinedFilter: "user_messaging_channels",
+ filterValues: ["user_id": "user123"],
+ sortValues: ["sort_field": "last_message_at"],
+ pageSize: pageSize
+ )
+ query.options = .watch
+
+ let expectedData: [String: Any] = [
+ "limit": pageSize,
+ "predefined_filter": "user_messaging_channels",
+ "filter_values": ["user_id": "user123"],
+ "sort_values": ["sort_field": "last_message_at"],
+ "watch": true
+ ]
+
+ let expectedJSON = try JSONSerialization.data(withJSONObject: expectedData, options: [])
+ let encodedJSON = try JSONEncoder.default.encode(query)
+
+ AssertJSONEqual(expectedJSON, encodedJSON)
+ }
+
+ func test_channelListQuery_predefinedFilter_omitsEmptyValueDictionaries() throws {
+ let pageSize = Int.channelsPageSize
+ var query = ChannelListQuery(
+ predefinedFilter: "user_messaging_channels",
+ pageSize: pageSize
+ )
+ query.options = .watch
+
+ let expectedData: [String: Any] = [
+ "limit": pageSize,
+ "predefined_filter": "user_messaging_channels",
+ "watch": true
+ ]
+
+ let expectedJSON = try JSONSerialization.data(withJSONObject: expectedData, options: [])
+ let encodedJSON = try JSONEncoder.default.encode(query)
+
+ AssertJSONEqual(expectedJSON, encodedJSON)
+ }
+
+ func test_channelListQuery_predefinedFilter_setsExpectedProperties() {
+ let query = ChannelListQuery(
+ predefinedFilter: "team_channels",
+ filterValues: ["channel_type": "messaging"],
+ sortValues: ["sort_field": "created_at"]
+ )
+
+ XCTAssertEqual(query.predefinedFilter, "team_channels")
+ XCTAssertEqual(query.filterValues?["channel_type"], "messaging")
+ XCTAssertEqual(query.sortValues?["sort_field"], "created_at")
+ XCTAssertTrue(query.sort.isEmpty)
+ }
+
+ func test_channelListQuery_traditionalInit_leavesPredefinedFieldsNil() {
+ let query = ChannelListQuery(filter: .equal(.cid, to: .unique))
+
+ XCTAssertNil(query.predefinedFilter)
+ XCTAssertNil(query.filterValues)
+ XCTAssertNil(query.sortValues)
+ }
+
+ // MARK: - queryHash
+
+ func test_queryHash_traditionalQuery_equalsFilterFilterHash() {
+ let filter = Filter.equal(.cid, to: .unique)
+ let query = ChannelListQuery(filter: filter)
+
+ XCTAssertEqual(query.queryHash, filter.filterHash)
+ }
+
+ func test_queryHash_predefinedQuery_isStableAcrossKeyOrdering() {
+ let queryA = ChannelListQuery(
+ predefinedFilter: "team_channels",
+ filterValues: ["channel_type": "messaging", "team_name": "engineering", "user_id": "user123"],
+ sortValues: ["primary_sort": "last_message_at", "secondary_sort": "created_at"]
+ )
+ let queryB = ChannelListQuery(
+ predefinedFilter: "team_channels",
+ filterValues: ["user_id": "user123", "team_name": "engineering", "channel_type": "messaging"],
+ sortValues: ["secondary_sort": "created_at", "primary_sort": "last_message_at"]
+ )
+
+ XCTAssertEqual(queryA.queryHash, queryB.queryHash)
+ }
+
+ func test_queryHash_predefinedQuery_differsWhenFilterValuesDiffer() {
+ let queryA = ChannelListQuery(
+ predefinedFilter: "user_messaging_channels",
+ filterValues: ["user_id": "user123"]
+ )
+ let queryB = ChannelListQuery(
+ predefinedFilter: "user_messaging_channels",
+ filterValues: ["user_id": "user456"]
+ )
+
+ XCTAssertNotEqual(queryA.queryHash, queryB.queryHash)
+ }
+
+ func test_queryHash_predefinedQuery_differsFromTraditionalQuery() {
+ let predefinedQuery = ChannelListQuery(predefinedFilter: "user_messaging_channels")
+ let traditionalQuery = ChannelListQuery(filter: .and([]))
+
+ XCTAssertNotEqual(predefinedQuery.queryHash, traditionalQuery.queryHash)
+ }
+
func test_channelListQuery_encodedOmitsLimitsWhenNil() throws {
let cid = ChannelId.unique
let filter = Filter.equal(.cid, to: cid)
diff --git a/Tests/StreamChatTests/Query/Filter_Tests.swift b/Tests/StreamChatTests/Query/Filter_Tests.swift
index 6a87bbbe58a..3afb887d0e0 100644
--- a/Tests/StreamChatTests/Query/Filter_Tests.swift
+++ b/Tests/StreamChatTests/Query/Filter_Tests.swift
@@ -115,4 +115,129 @@ final class Filter_Tests: XCTestCase {
XCTAssertEqual(filter, decoded)
}
}
+
+ // MARK: - Implicit `$eq` decoding
+
+ func test_filterDecoding_implicitEqual_string() throws {
+ let filter: Filter = try #"{"name":"general"}"#.deserializeFilterThrows()
+ XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue)
+ XCTAssertEqual(filter.key, "name")
+ XCTAssertEqual(filter.value as? String, "general")
+ }
+
+ func test_filterDecoding_implicitEqual_bool() throws {
+ let filter: Filter = try #"{"frozen":true}"#.deserializeFilterThrows()
+ XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue)
+ XCTAssertEqual(filter.key, "frozen")
+ XCTAssertEqual(filter.value as? Bool, true)
+ }
+
+ func test_filterDecoding_implicitEqual_int() throws {
+ let filter: Filter = try #"{"member_count":5}"#.deserializeFilterThrows()
+ XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue)
+ XCTAssertEqual(filter.key, "member_count")
+ XCTAssertEqual(filter.value as? Int, 5)
+ }
+
+ func test_filterDecoding_implicitEqual_array() throws {
+ let filter: Filter = try #"{"members":["r2-d2","c-3po"]}"#.deserializeFilterThrows()
+ XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue)
+ XCTAssertEqual(filter.key, "members")
+ XCTAssertEqual(filter.value as? [String], ["r2-d2", "c-3po"])
+ }
+
+ func test_filterDecoding_longFormStillWorks() throws {
+ let filter: Filter = try #"{"name":{"$eq":"general"}}"#.deserializeFilterThrows()
+ XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue)
+ XCTAssertEqual(filter.key, "name")
+ XCTAssertEqual(filter.value as? String, "general")
+ }
+
+ func test_filterDecoding_implicitEqual_double() throws {
+ let filter: Filter = try #"{"test_key_Double":13.5}"#.deserializeFilterThrows()
+ XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue)
+ XCTAssertEqual(filter.key, "test_key_Double")
+ XCTAssertEqual(filter.value as? Double, 13.5)
+ }
+
+ func test_filterDecoding_implicitEqual_intArray() throws {
+ let filter: Filter = try #"{"test_key_ArrayInt":[1,2,3]}"#.deserializeFilterThrows()
+ XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue)
+ XCTAssertEqual(filter.key, "test_key_ArrayInt")
+ XCTAssertEqual(filter.value as? [Int], [1, 2, 3])
+ }
+
+ // MARK: - Implicit `$and` decoding
+
+ func test_filterDecoding_mixedFieldAndGroupOperator_decodesAsImplicitAnd() throws {
+ // A field key and a group-operator key at the same level are ANDed; neither is dropped.
+ let filter: Filter = try #"{"name":"general","$or":[{"a":"1"},{"b":"2"}]}"#.deserializeFilterThrows()
+
+ XCTAssertEqual(filter.operator, FilterOperator.and.rawValue)
+ let children = try XCTUnwrap(filter.value as? [Filter])
+ XCTAssertEqual(children.count, 2)
+ XCTAssertNotNil(children.first { $0.key == "name" })
+ XCTAssertNotNil(children.first { $0.operator == FilterOperator.or.rawValue })
+ }
+
+ func test_filterDecoding_multipleGroupOperators_decodeAsImplicitAnd() throws {
+ // Two group operators at the same level are ANDed.
+ let filter: Filter = try #"{"$and":[{"a":"1"}],"$or":[{"b":"2"}]}"#.deserializeFilterThrows()
+
+ XCTAssertEqual(filter.operator, FilterOperator.and.rawValue)
+ let children = try XCTUnwrap(filter.value as? [Filter])
+ XCTAssertEqual(children.count, 2)
+ XCTAssertNotNil(children.first { $0.operator == FilterOperator.and.rawValue })
+ XCTAssertNotNil(children.first { $0.operator == FilterOperator.or.rawValue })
+ }
+
+ func test_filterDecoding_implicitAnd_threeBareKeys_decodesAllLeaves() throws {
+ let filter: Filter = try #"{"a":1,"b":"x","c":true}"#.deserializeFilterThrows()
+
+ XCTAssertEqual(filter.operator, FilterOperator.and.rawValue)
+ let children = try XCTUnwrap(filter.value as? [Filter])
+ XCTAssertEqual(children.count, 3)
+ XCTAssertEqual(children.first { $0.key == "a" }?.value as? Int, 1)
+ XCTAssertEqual(children.first { $0.key == "b" }?.value as? String, "x")
+ XCTAssertEqual(children.first { $0.key == "c" }?.value as? Bool, true)
+ }
+
+ func test_filterDecoding_implicitMultiKeyNestedInsideGroup_decodesAsImplicitAnd() throws {
+ // The multi-key → implicit-`$and` rule must apply at every level, not just the top.
+ let filter: Filter = try #"{"$or":[{"a":1,"b":2},{"c":3}]}"#.deserializeFilterThrows()
+
+ XCTAssertEqual(filter.operator, FilterOperator.or.rawValue)
+ let orChildren = try XCTUnwrap(filter.value as? [Filter])
+ XCTAssertEqual(orChildren.count, 2)
+
+ // The multi-key object decodes to a nested implicit `$and`.
+ let nestedAnd = try XCTUnwrap(orChildren.first { $0.operator == FilterOperator.and.rawValue })
+ let nestedAndChildren = try XCTUnwrap(nestedAnd.value as? [Filter])
+ XCTAssertEqual(nestedAndChildren.count, 2)
+ XCTAssertNotNil(nestedAndChildren.first { $0.key == "a" })
+ XCTAssertNotNil(nestedAndChildren.first { $0.key == "b" })
+
+ // The single-key object stays a plain leaf.
+ XCTAssertNotNil(orChildren.first { $0.key == "c" })
+ }
+
+ func test_filterDecoding_nestedGroups_decodeRecursively() throws {
+ let filter: Filter = try #"{"$and":[{"$or":[{"a":1}]},{"x":1}]}"#.deserializeFilterThrows()
+
+ XCTAssertEqual(filter.operator, FilterOperator.and.rawValue)
+ let children = try XCTUnwrap(filter.value as? [Filter])
+ XCTAssertEqual(children.count, 2)
+ XCTAssertNotNil(children.first { $0.operator == FilterOperator.or.rawValue })
+ XCTAssertNotNil(children.first { $0.key == "x" })
+ }
+
+ func test_filterDecoding_norGroup_decodes() throws {
+ let filter: Filter = try #"{"$nor":[{"a":1},{"b":2}]}"#.deserializeFilterThrows()
+
+ XCTAssertEqual(filter.operator, FilterOperator.nor.rawValue)
+ let children = try XCTUnwrap(filter.value as? [Filter])
+ XCTAssertEqual(children.count, 2)
+ XCTAssertNotNil(children.first { $0.key == "a" })
+ XCTAssertNotNil(children.first { $0.key == "b" })
+ }
}
diff --git a/Tests/StreamChatTests/Query/Pagination_Tests.swift b/Tests/StreamChatTests/Query/Pagination_Tests.swift
index 8a0e8f75a28..2f235a7b41e 100644
--- a/Tests/StreamChatTests/Query/Pagination_Tests.swift
+++ b/Tests/StreamChatTests/Query/Pagination_Tests.swift
@@ -36,6 +36,13 @@ final class Pagination_Tests: XCTestCase {
AssertJSONEqual(encodedJSON, expectedJSON)
}
+ func test_pagination_backendDefaultPageSize_omitsLimit() throws {
+ let pagination = Pagination(pageSize: .backendDefaultPageSize)
+ let encodedJSON = try JSONEncoder.default.encode(pagination)
+ let expectedJSON = try JSONSerialization.data(withJSONObject: [String: Any](), options: [])
+ AssertJSONEqual(encodedJSON, expectedJSON)
+ }
+
func test_messagesPagination_Encoding() throws {
// Create pagination
let pagination = MessagesPagination(pageSize: .channelMembersPageSize, parameter: .lessThan("testId"))
diff --git a/Tests/StreamChatTests/Query/Sorting/ListDatabaseObserver+Sorting_Tests.swift b/Tests/StreamChatTests/Query/Sorting/ListDatabaseObserver+Sorting_Tests.swift
index 997a96d69b2..5a1a18a343b 100644
--- a/Tests/StreamChatTests/Query/Sorting/ListDatabaseObserver+Sorting_Tests.swift
+++ b/Tests/StreamChatTests/Query/Sorting/ListDatabaseObserver+Sorting_Tests.swift
@@ -354,7 +354,7 @@ final class ListDatabaseObserver_Sorting_Tests: XCTestCase {
)
}
- guard let queryDTO = session.channelListQuery(filterHash: self.query.filter.filterHash) else {
+ guard let queryDTO = session.channelListQuery(self.query) else {
return
}
for channel in channels {
diff --git a/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift b/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift
index f88fee86d82..93d3d95c270 100644
--- a/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift
+++ b/Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift
@@ -227,6 +227,144 @@ class SyncRepository_Tests: XCTestCase {
XCTAssertCall("runQueuedRequests(completion:)", on: offlineRequestsRepository, times: 1)
}
+ func test_syncLocalState_groupedChannelList_callsQueryGroupedChannelsAndSkipsRefresh() throws {
+ let cid = ChannelId.unique
+ try prepareForSyncLocalStorage(
+ createUser: true,
+ lastSynchedEventDate: Date().addingTimeInterval(-3600),
+ createChannel: true,
+ cid: cid
+ )
+
+ var groupedQuery = ChannelListQuery(filter: .exists(.cid))
+ groupedQuery.groupKey = "all"
+ let channelList = ChannelList_Mock.mock(query: groupedQuery, client: client)
+ repository.startTrackingChannelList(channelList)
+ let refreshedGroup = ChannelGroup(groupKey: "all", channels: [.mock(cid: cid)], unreadChannels: 0)
+ channelListUpdater.queryGroupedChannels_result = .success([refreshedGroup])
+
+ waitForSyncLocalStateRun()
+
+ XCTAssertEqual(channelListUpdater.queryGroupedChannels_callCount, 1)
+ XCTAssertEqual(channelList.refreshLoadedChannelsCallCount, 0)
+ XCTAssertEqual(["all"], channelListUpdater.queryGroupedChannels_groups.last??.keys.sorted())
+ }
+
+ func test_syncLocalState_groupedChannelList_passesPersistedWatchAndPresenceToQueryGroupedChannels() throws {
+ let cid = ChannelId.unique
+ try prepareForSyncLocalStorage(
+ createUser: true,
+ lastSynchedEventDate: Date().addingTimeInterval(-3600),
+ createChannel: true,
+ cid: cid
+ )
+
+ var groupedQuery = ChannelListQuery(filter: .exists(.cid))
+ groupedQuery.groupKey = "all"
+ let channelList = ChannelList_Mock.mock(query: groupedQuery, client: client)
+ repository.startTrackingChannelList(channelList)
+
+ // Pre-populate the persisted state for the group with both flags enabled.
+ try database.writeSynchronously { session in
+ let queryDTO = session.saveQuery(query: ChannelListQuery(groupKey: "all"))
+ queryDTO.watch = true
+ queryDTO.presence = true
+ }
+ let refreshedGroup = ChannelGroup(groupKey: "all", channels: [.mock(cid: cid)], unreadChannels: 0)
+ channelListUpdater.queryGroupedChannels_result = .success([refreshedGroup])
+
+ waitForSyncLocalStateRun()
+
+ XCTAssertEqual(channelListUpdater.queryGroupedChannels_callCount, 1)
+ XCTAssertEqual([true], channelListUpdater.queryGroupedChannels_watchValues)
+ XCTAssertEqual([true], channelListUpdater.queryGroupedChannels_presenceValues)
+ }
+
+ func test_syncLocalState_mixedChannelLists_callsGroupedOnceAndRefreshesOnlyStandard() throws {
+ let groupedCid = ChannelId.unique
+ let standardCid = ChannelId.unique
+ try prepareForSyncLocalStorage(
+ createUser: true,
+ lastSynchedEventDate: Date().addingTimeInterval(-3600),
+ createChannel: true,
+ cid: groupedCid
+ )
+
+ var groupedQuery = ChannelListQuery(filter: .exists(.cid))
+ groupedQuery.groupKey = "current"
+ let groupedChannelList = ChannelList_Mock.mock(query: groupedQuery, client: client)
+ repository.startTrackingChannelList(groupedChannelList)
+
+ let standardChannelList = ChannelList_Mock.mock(query: .init(filter: .in(.cid, values: [standardCid])), client: client)
+ standardChannelList.refreshLoadedChannelsResult = .success(Set([standardCid]))
+ repository.startTrackingChannelList(standardChannelList)
+
+ let refreshedGroup = ChannelGroup(groupKey: "current", channels: [.mock(cid: groupedCid)], unreadChannels: 0)
+ channelListUpdater.queryGroupedChannels_result = .success([refreshedGroup])
+
+ waitForSyncLocalStateRun()
+
+ XCTAssertEqual(channelListUpdater.queryGroupedChannels_callCount, 1)
+ XCTAssertEqual(groupedChannelList.refreshLoadedChannelsCallCount, 0)
+ XCTAssertEqual(standardChannelList.refreshLoadedChannelsCallCount, 1)
+ XCTAssertEqual(["current"], channelListUpdater.queryGroupedChannels_groups.last??.keys.sorted())
+ }
+
+ func test_syncLocalState_multipleGroupedChannelLists_dedupesGroupKeysPassedToUpdater() throws {
+ let cid = ChannelId.unique
+ try prepareForSyncLocalStorage(
+ createUser: true,
+ lastSynchedEventDate: Date().addingTimeInterval(-3600),
+ createChannel: true,
+ cid: cid
+ )
+
+ var groupedQuery = ChannelListQuery(filter: .exists(.cid))
+ groupedQuery.groupKey = "all"
+ let firstList = ChannelList_Mock.mock(query: groupedQuery, client: client)
+ let secondList = ChannelList_Mock.mock(query: groupedQuery, client: client)
+ repository.startTrackingChannelList(firstList)
+ repository.startTrackingChannelList(secondList)
+
+ let refreshedGroup = ChannelGroup(groupKey: "all", channels: [.mock(cid: cid)], unreadChannels: 0)
+ channelListUpdater.queryGroupedChannels_result = .success([refreshedGroup])
+
+ waitForSyncLocalStateRun()
+
+ XCTAssertEqual(channelListUpdater.queryGroupedChannels_callCount, 1)
+ XCTAssertEqual(["all"], channelListUpdater.queryGroupedChannels_groups.last??.keys.sorted())
+ }
+
+ func test_syncLocalState_multipleGroupedChannelLists_passesAllDistinctGroupKeysToUpdater() throws {
+ let cid = ChannelId.unique
+ try prepareForSyncLocalStorage(
+ createUser: true,
+ lastSynchedEventDate: Date().addingTimeInterval(-3600),
+ createChannel: true,
+ cid: cid
+ )
+
+ var newQuery = ChannelListQuery(filter: .exists(.cid))
+ newQuery.groupKey = "new"
+ var currentQuery = ChannelListQuery(filter: .exists(.cid))
+ currentQuery.groupKey = "current"
+ let newList = ChannelList_Mock.mock(query: newQuery, client: client)
+ let currentList = ChannelList_Mock.mock(query: currentQuery, client: client)
+ repository.startTrackingChannelList(newList)
+ repository.startTrackingChannelList(currentList)
+
+ let refreshedGroups = [
+ ChannelGroup(groupKey: "new", channels: [.mock(cid: cid)], unreadChannels: 0),
+ ChannelGroup(groupKey: "current", channels: [.mock(cid: cid)], unreadChannels: 0)
+ ]
+ channelListUpdater.queryGroupedChannels_result = .success(refreshedGroups)
+
+ waitForSyncLocalStateRun()
+
+ XCTAssertEqual(channelListUpdater.queryGroupedChannels_callCount, 1)
+ XCTAssertEqual(["current", "new"], channelListUpdater.queryGroupedChannels_groups.last??.keys.sorted())
+ }
+
func test_syncLocalState_ignoresTheCooldown() throws {
let lastSyncDate = Date()
let cid = ChannelId.unique
diff --git a/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift b/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift
index 4f7f6102816..68adb3983e6 100644
--- a/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift
+++ b/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift
@@ -31,8 +31,9 @@ final class ChannelList_Tests: XCTestCase {
func test_restoringState_whenDatabaseHasEntries_thenStateIsUpdated() async throws {
let channelListPayload = makeMatchingChannelListPayload(channelCount: 5, createdAtOffset: 0)
+ let query = await channelList.state.query
try await env.client.mockDatabaseContainer.write { session in
- session.saveChannelList(payload: channelListPayload, query: self.channelList.query)
+ session.saveChannelList(payload: channelListPayload, query: query)
}
await setUpChannelList(usesMockedChannelUpdater: true)
XCTAssertEqual(channelListPayload.channels.map(\.channel.cid.rawValue), await channelList.state.channels.map(\.cid.rawValue))
@@ -41,11 +42,12 @@ final class ChannelList_Tests: XCTestCase {
func test_restoringState_whenDatabaseHasEntriesWhichShouldBeIgnored_thenStateOnlyIncludesQueryMatchingResults() async throws {
let matchingChannelListPayload = makeMatchingChannelListPayload(channelCount: 5, createdAtOffset: 0)
let deletedChannelPayload = makeMatchingChannelPayload(createdAtOffset: 5)
+ let query = await channelList.state.query
try await env.client.mockDatabaseContainer.write { session in
// These match with the query
- session.saveChannelList(payload: matchingChannelListPayload, query: self.channelList.query)
+ session.saveChannelList(payload: matchingChannelListPayload, query: query)
// Should be ignored because it was deleted
- let dto = try session.saveChannel(payload: deletedChannelPayload, query: self.channelList.query, cache: nil)
+ let dto = try session.saveChannel(payload: deletedChannelPayload, query: query, cache: nil)
dto.deletedAt = .unique
// Unrelated channel to the query
try session.saveChannel(payload: self.dummyPayload(with: .unique))
@@ -59,8 +61,9 @@ final class ChannelList_Tests: XCTestCase {
func test_get_whenLocalStoreHasChannels_thenGetResetsChannels() async throws {
// Existing state
let channelListPayload = makeMatchingChannelListPayload(channelCount: 10, createdAtOffset: 0)
+ let query = await channelList.state.query
try await env.client.mockDatabaseContainer.write { session in
- session.saveChannelList(payload: channelListPayload, query: self.channelList.query)
+ session.saveChannelList(payload: channelListPayload, query: query)
}
await setUpChannelList(usesMockedChannelUpdater: false)
@@ -86,6 +89,191 @@ final class ChannelList_Tests: XCTestCase {
await XCTAssertEqual(nextChannelListPayload.channels.map(\.channel.cid.rawValue), channelList.state.channels.map(\.cid.rawValue))
}
+ // MARK: - Predefined filter resolution
+
+ func test_restoringState_predefinedFilterQuery_appliesCachedResolvedFilterBeforeGet() async throws {
+ let predefinedQuery = ChannelListQuery(
+ predefinedFilter: "user_per_channel_type_channels",
+ filterValues: ["user_id": "r2-d2"]
+ )
+ let resolvedFilterJSON = #"{"type":"messaging"}"#.data(using: .utf8)!
+ let resolvedSortJSON = #"[{"field":"last_message_at","direction":-1}]"#.data(using: .utf8)!
+ try await env.client.mockDatabaseContainer.write { session in
+ let dto = session.saveQuery(query: predefinedQuery)
+ dto.filterJSONData = resolvedFilterJSON
+ dto.sortJSONData = resolvedSortJSON
+ }
+
+ await setUpChannelList(usesMockedChannelUpdater: true, query: predefinedQuery)
+
+ let stateQuery = await channelList.state.query
+ XCTAssertEqual(stateQuery.filter.key, "type")
+ XCTAssertEqual(stateQuery.filter.value as? String, "messaging")
+ XCTAssertEqual(stateQuery.sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey)
+ }
+
+ func test_get_predefinedFilterQuery_appliesResolvedFilterAndMirrorsOnList() async throws {
+ // GIVEN: a ChannelList built with a predefined query (placeholder filter + empty sort)
+ let predefinedQuery = ChannelListQuery(
+ predefinedFilter: "user_per_channel_type_channels",
+ filterValues: ["user_id": "r2-d2"]
+ )
+ await setUpChannelList(usesMockedChannelUpdater: true, query: predefinedQuery)
+
+ // AND: the DTO carries server-resolved filter/sort JSON
+ let resolvedFilterJSON = #"{"type":"messaging"}"#.data(using: .utf8)!
+ let resolvedSortJSON = #"[{"field":"last_message_at","direction":-1}]"#.data(using: .utf8)!
+ try await env.client.mockDatabaseContainer.write { session in
+ let dto = session.saveQuery(query: predefinedQuery)
+ dto.filterJSONData = resolvedFilterJSON
+ dto.sortJSONData = resolvedSortJSON
+ }
+ env.channelListUpdaterMock.update_completion_result = .success([])
+
+ // WHEN: get() completes
+ try await channelList.get()
+
+ // THEN: state.query exposes the resolved values
+ let stateQuery = await channelList.state.query
+ XCTAssertEqual(stateQuery.filter.key, "type")
+ XCTAssertEqual(stateQuery.filter.value as? String, "messaging")
+ XCTAssertEqual(stateQuery.sort.count, 1)
+ XCTAssertEqual(stateQuery.sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey)
+ XCTAssertEqual(stateQuery.sort.first?.direction, -1)
+ }
+
+ func test_get_nonPredefinedQuery_leavesQueryUnchanged() async throws {
+ await setUpChannelList(usesMockedChannelUpdater: true)
+ env.channelListUpdaterMock.update_completion_result = .success([])
+
+ let queryBefore = await channelList.state.query
+
+ try await channelList.get()
+
+ let queryAfter = await channelList.state.query
+ XCTAssertEqual(queryAfter.filter.filterHash, queryBefore.filter.filterHash)
+ XCTAssertEqual(queryAfter.sort.map(\.description), queryBefore.sort.map(\.description))
+ }
+
+ func test_get_whenQueryHasGroupKey_fetchesFirstPageWithoutCursor() async throws {
+ let groupedQuery = ChannelListQuery(groupKey: "all")
+ let environment = env.channelListEnvironment(usesMockedUpdater: true)
+ channelList = await ChannelList(
+ query: groupedQuery,
+ dynamicFilter: nil,
+ client: env.client,
+ environment: environment
+ )
+ _ = await channelList.state
+ env.channelListUpdaterMock.queryGroupedChannels_result = .success([
+ ChannelGroup(groupKey: "all", channels: [], unreadChannels: 0, next: nil)
+ ])
+
+ try await channelList.get()
+
+ XCTAssertEqual(1, env.channelListUpdaterMock.queryGroupedChannels_callCount)
+ let groups = env.channelListUpdaterMock.queryGroupedChannels_groups.first ?? nil
+ XCTAssertEqual(["all"], groups?.keys.sorted())
+ XCTAssertNil(groups?["all"]?.limit)
+ XCTAssertNil(groups?["all"]?.next)
+ XCTAssertTrue(env.channelListUpdaterMock.update_queries.isEmpty)
+ }
+
+ func test_loadMoreChannels_whenQueryHasGroupKey_readsCursorFromQueryDTO() async throws {
+ let groupedQuery = ChannelListQuery(groupKey: "all")
+ let environment = env.channelListEnvironment(usesMockedUpdater: true)
+ channelList = await ChannelList(
+ query: groupedQuery,
+ dynamicFilter: nil,
+ client: env.client,
+ environment: environment
+ )
+ _ = await channelList.state
+ try await env.client.mockDatabaseContainer.write { session in
+ let queryDTO = session.saveQuery(query: groupedQuery)
+ queryDTO.next = "cursor-1"
+ }
+ env.channelListUpdaterMock.queryGroupedChannels_result = .success([
+ ChannelGroup(
+ groupKey: "all",
+ channels: [],
+ unreadChannels: 0,
+ next: "cursor-2"
+ )
+ ])
+
+ _ = try await channelList.loadMoreChannels(limit: 5)
+
+ XCTAssertEqual(1, env.channelListUpdaterMock.queryGroupedChannels_callCount)
+ let groups = env.channelListUpdaterMock.queryGroupedChannels_groups.first ?? nil
+ XCTAssertEqual(["all"], groups?.keys.sorted())
+ XCTAssertEqual("cursor-1", groups?["all"]?.next)
+ }
+
+ func test_loadMoreChannels_whenQueryHasGroupKey_propagatesPersistedWatchAndPresence() async throws {
+ let groupedQuery = ChannelListQuery(groupKey: "all")
+ let environment = env.channelListEnvironment(usesMockedUpdater: true)
+ channelList = await ChannelList(
+ query: groupedQuery,
+ dynamicFilter: nil,
+ client: env.client,
+ environment: environment
+ )
+ _ = await channelList.state
+ try await env.client.mockDatabaseContainer.write { session in
+ let queryDTO = session.saveQuery(query: groupedQuery)
+ queryDTO.next = "cursor-1"
+ queryDTO.watch = true
+ queryDTO.presence = true
+ }
+ env.channelListUpdaterMock.queryGroupedChannels_result = .success([
+ ChannelGroup(
+ groupKey: "all",
+ channels: [],
+ unreadChannels: 0,
+ next: "cursor-2"
+ )
+ ])
+
+ _ = try await channelList.loadMoreChannels(limit: 5)
+
+ XCTAssertEqual([true], env.channelListUpdaterMock.queryGroupedChannels_watchValues)
+ XCTAssertEqual([true], env.channelListUpdaterMock.queryGroupedChannels_presenceValues)
+ }
+
+ func test_loadMoreChannels_whenQueryDTOHasNoNextCursor_marksAsFullyLoaded() async throws {
+ let groupedQuery = ChannelListQuery(groupKey: "all")
+ let environment = env.channelListEnvironment(usesMockedUpdater: true)
+ channelList = await ChannelList(
+ query: groupedQuery,
+ dynamicFilter: nil,
+ client: env.client,
+ environment: environment
+ )
+ _ = await channelList.state
+ try await env.client.mockDatabaseContainer.write { session in
+ _ = session.saveQuery(query: groupedQuery)
+ }
+
+ let returned = try await channelList.loadMoreChannels(limit: 5)
+
+ XCTAssertEqual([], returned)
+ XCTAssertEqual(0, env.channelListUpdaterMock.queryGroupedChannels_callCount)
+ let fullyLoaded = await channelList.state.hasLoadedAllPreviousChannels
+ XCTAssertTrue(fullyLoaded)
+ }
+
+ func test_loadMoreChannels_withoutGroupHandler_usesOffsetPath() async throws {
+ await setUpChannelList(usesMockedChannelUpdater: true, pageSize: 2)
+ let responseChannels = makeChannels(count: 2, createdAtOffset: 0)
+ env.channelListUpdaterMock.update_completion_result = .success(responseChannels)
+
+ _ = try await channelList.loadMoreChannels(limit: 2)
+
+ XCTAssertEqual(0, env.channelListUpdaterMock.queryGroupedChannels_callCount)
+ XCTAssertEqual(1, env.channelListUpdaterMock.update_queries.count)
+ }
+
// MARK: - Pagination and Channel Updater Arguments
func test_loadChannels_whenChannelUpdaterSucceeds_thenLoadSucceeds() async throws {
@@ -95,10 +283,11 @@ final class ChannelList_Tests: XCTestCase {
let pagination = Pagination(pageSize: pageSize, offset: 0)
let result = try await channelList.loadChannels(with: pagination)
+ let query = await channelList.state.query
XCTAssertEqual(env.channelListUpdaterMock.update_queries.count, 1)
- XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.filter, channelList.query.filter)
- XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.sort, channelList.query.sort)
+ XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.filter, query.filter)
+ XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.sort, query.sort)
XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.pagination.pageSize, pageSize)
XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.pagination.offset, 0)
XCTAssertEqual(responseChannels, result)
@@ -115,14 +304,36 @@ final class ChannelList_Tests: XCTestCase {
let responseChannels = makeChannels(count: pageSize, createdAtOffset: 0)
env.channelListUpdaterMock.update_completion_result = .success(responseChannels)
let result = try await channelList.loadMoreChannels(limit: pageSize)
+ let query = await channelList.state.query
XCTAssertEqual(env.channelListUpdaterMock.update_queries.count, 1)
- XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.filter, channelList.query.filter)
- XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.sort, channelList.query.sort)
+ XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.filter, query.filter)
+ XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.sort, query.sort)
XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.pagination.pageSize, pageSize)
XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.pagination.offset, 0)
XCTAssertEqual(responseChannels, result)
}
+
+ func test_loadMoreChannels_predefinedFilterQuery_appliesResolvedFilterAndMirrorsOnList() async throws {
+ let predefinedQuery = ChannelListQuery(
+ predefinedFilter: "user_per_channel_type_channels",
+ filterValues: ["user_id": "r2-d2"]
+ )
+ await setUpChannelList(usesMockedChannelUpdater: true, query: predefinedQuery)
+ try await env.client.mockDatabaseContainer.write { session in
+ let dto = session.saveQuery(query: predefinedQuery)
+ dto.filterJSONData = #"{"type":"messaging"}"#.data(using: .utf8)!
+ dto.sortJSONData = #"[{"field":"last_message_at","direction":-1}]"#.data(using: .utf8)!
+ }
+ env.channelListUpdaterMock.update_completion_result = .success([])
+
+ try await channelList.loadMoreChannels()
+
+ let stateQuery = await channelList.state.query
+ XCTAssertEqual(stateQuery.filter.key, "type")
+ XCTAssertEqual(stateQuery.filter.value as? String, "messaging")
+ XCTAssertEqual(stateQuery.sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey)
+ }
func test_loadMoreChannels_whenChannelUpdaterFails_thenLoadFails() async throws {
env.channelListUpdaterMock.update_completion_result = .failure(testError)
@@ -146,8 +357,9 @@ final class ChannelList_Tests: XCTestCase {
func test_loadMoreChannels_whenAPIRequestSucceeds_thenStateUpdates() async throws {
// Initial DB state
let existingChannelListPayload = makeMatchingChannelListPayload(channelCount: 2, createdAtOffset: 0)
+ let query = await channelList.state.query
try await env.client.mockDatabaseContainer.write { session in
- session.saveChannelList(payload: existingChannelListPayload, query: self.channelList.query)
+ session.saveChannelList(payload: existingChannelListPayload, query: query)
}
await setUpChannelList(usesMockedChannelUpdater: false)
@@ -234,8 +446,9 @@ final class ChannelList_Tests: XCTestCase {
XCTAssertEqual(incomingChannelListPayload.channels.map(\.channel.cid.rawValue), channels.map(\.cid.rawValue))
expectation.fulfill()
}
+ let query = await channelList.state.query
try await env.client.mockDatabaseContainer.write { session in
- session.saveChannelList(payload: incomingChannelListPayload, query: self.channelList.query)
+ session.saveChannelList(payload: incomingChannelListPayload, query: query)
}
await fulfillment(of: [expectation], timeout: defaultTimeout)
cancellable.cancel()
@@ -263,8 +476,9 @@ final class ChannelList_Tests: XCTestCase {
XCTAssertTrue(channels.allSatisfy(\.isPinned), channels.filter { !$0.isPinned }.map(\.cid.rawValue).joined())
expectation.fulfill()
}
+ let query = await channelList.state.query
try await env.client.mockDatabaseContainer.write { session in
- session.saveChannelList(payload: incomingChannelListPayload, query: self.channelList.query)
+ session.saveChannelList(payload: incomingChannelListPayload, query: query)
}
await fulfillment(of: [expectation], timeout: defaultTimeout)
cancellable.cancel()
@@ -300,8 +514,9 @@ final class ChannelList_Tests: XCTestCase {
XCTAssertEqual([true, true, false, false, false], channels.map(\.isPinned))
expectation.fulfill()
}
+ let query = await channelList.state.query
try await env.client.mockDatabaseContainer.write { session in
- session.saveChannelList(payload: incomingChannelListPayload, query: self.channelList.query)
+ session.saveChannelList(payload: incomingChannelListPayload, query: query)
}
await fulfillment(of: [expectation], timeout: defaultTimeout)
cancellable.cancel()
@@ -336,8 +551,9 @@ final class ChannelList_Tests: XCTestCase {
XCTAssertTrue(channels.allSatisfy(\.isArchived), channels.filter { !$0.isArchived }.map(\.cid.rawValue).joined())
expectation.fulfill()
}
+ let query = await channelList.state.query
try await env.client.mockDatabaseContainer.write { session in
- session.saveChannelList(payload: incomingChannelListPayload, query: self.channelList.query)
+ session.saveChannelList(payload: incomingChannelListPayload, query: query)
}
await fulfillment(of: [expectation], timeout: defaultTimeout)
cancellable.cancel()
@@ -409,8 +625,9 @@ final class ChannelList_Tests: XCTestCase {
await setUpChannelList(usesMockedChannelUpdater: false, dynamicFilter: { _ in true })
// Create channel list
let existingChannelListPayload = makeMatchingChannelListPayload(channelCount: 1, createdAtOffset: 0)
+ let query = await channelList.state.query
try await env.client.mockDatabaseContainer.write { session in
- session.saveChannelList(payload: existingChannelListPayload, query: self.channelList.query)
+ session.saveChannelList(payload: existingChannelListPayload, query: query)
}
// New channel event
@@ -450,8 +667,9 @@ final class ChannelList_Tests: XCTestCase {
// Create channel list
let existingChannelListPayload = makeMatchingChannelListPayload(channelCount: 1, createdAtOffset: 0)
let existingCid = try XCTUnwrap(existingChannelListPayload.channels.first?.channel.cid)
+ let query = await channelList.state.query
try await env.client.mockDatabaseContainer.write { session in
- session.saveChannelList(payload: existingChannelListPayload, query: self.channelList.query)
+ session.saveChannelList(payload: existingChannelListPayload, query: query)
}
// Ensure that the channel is in the state
XCTAssertEqual(existingChannelListPayload.channels.map(\.channel.cid.rawValue), await channelList.state.channels.map(\.cid.rawValue))
@@ -483,8 +701,9 @@ final class ChannelList_Tests: XCTestCase {
let pageCount = 2
let loadedCount = pageCount * Int.channelsPageSize
let existingChannelListPayload = makeMatchingChannelListPayload(channelCount: loadedCount, createdAtOffset: 0)
+ let query = await channelList.state.query
try await env.client.mockDatabaseContainer.write { session in
- session.saveChannelList(payload: existingChannelListPayload, query: self.channelList.query)
+ session.saveChannelList(payload: existingChannelListPayload, query: query)
}
// Ensure that the channel is in the state
@@ -514,14 +733,18 @@ final class ChannelList_Tests: XCTestCase {
usesMockedChannelUpdater: Bool,
loadState: Bool = true,
filter: Filter? = nil,
+ pageSize: Int = .channelsPageSize,
sort: [Sorting] = [.init(key: .createdAt, isAscending: true)],
- dynamicFilter: (@Sendable (ChatChannel) -> Bool)? = nil
+ dynamicFilter: (@Sendable (ChatChannel) -> Bool)? = nil,
+ query: ChannelListQuery? = nil
) {
+ let resolvedQuery = query ?? ChannelListQuery(
+ filter: filter ?? .in(.members, values: [memberId]),
+ sort: sort,
+ pageSize: pageSize
+ )
channelList = ChannelList(
- query: ChannelListQuery(
- filter: filter ?? .in(.members, values: [memberId]),
- sort: sort
- ),
+ query: resolvedQuery,
dynamicFilter: dynamicFilter,
client: env.client,
environment: env.channelListEnvironment(usesMockedUpdater: usesMockedChannelUpdater)
diff --git a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swift b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swift
index 44603e9665f..30fbc91942e 100644
--- a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swift
+++ b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swift
@@ -1417,6 +1417,146 @@ final class ChannelReadUpdaterMiddleware_Tests: XCTestCase {
}
}
+ // MARK: - ChannelUpdated group-change adjusts grouped unread counts
+
+ func test_channelUpdatedEvent_groupChange_withUnread_adjustsBothCounts() throws {
+ try database.writeSynchronously { session in
+ try session.mergeCurrentUserUnreadChannelCountsByGroup(["new": 5, "current": 3, "all": 8])
+ self.linkChannelToGroupedQueries(["new", "all"], session: session)
+ }
+
+ let event = try channelUpdatedEvent(group: "current")
+ try database.writeSynchronously { session in
+ _ = self.middleware.handle(event: event, session: session)
+ }
+
+ XCTAssertEqual(
+ ["new": 4, "current": 4, "all": 8],
+ database.viewContext.currentUser?.unreadChannelCountsByGroup
+ )
+ }
+
+ func test_channelUpdatedEvent_groupUnchanged_doesNotAdjust() throws {
+ try database.writeSynchronously { session in
+ try session.mergeCurrentUserUnreadChannelCountsByGroup(["new": 5, "all": 8])
+ self.linkChannelToGroupedQueries(["new", "all"], session: session)
+ }
+
+ let event = try channelUpdatedEvent(group: "new")
+ try database.writeSynchronously { session in
+ _ = self.middleware.handle(event: event, session: session)
+ }
+
+ XCTAssertEqual(
+ ["new": 5, "all": 8],
+ database.viewContext.currentUser?.unreadChannelCountsByGroup
+ )
+ }
+
+ func test_channelUpdatedEvent_zeroUnread_doesNotAdjust() throws {
+ try database.writeSynchronously { session in
+ try session.mergeCurrentUserUnreadChannelCountsByGroup(["new": 5, "current": 3, "all": 8])
+ self.linkChannelToGroupedQueries(["new", "all"], session: session)
+ // Drop the channel's unread count to zero via the read; mark the channel dirty so its
+ // `willSave` recomputes `currentUserUnreadMessagesCount` from the updated read.
+ if let readDTO = session.loadChannelRead(
+ cid: self.channelPayload.channel.cid,
+ userId: self.currentUserPayload.id
+ ) {
+ readDTO.unreadMessageCount = 0
+ }
+ if let channelDTO = session.channel(cid: self.channelPayload.channel.cid) {
+ channelDTO.currentUserUnreadMessagesCount = 0
+ }
+ }
+
+ let event = try channelUpdatedEvent(group: "current")
+ try database.writeSynchronously { session in
+ _ = self.middleware.handle(event: event, session: session)
+ }
+
+ XCTAssertEqual(
+ ["new": 5, "current": 3, "all": 8],
+ database.viewContext.currentUser?.unreadChannelCountsByGroup
+ )
+ }
+
+ func test_channelUpdatedEvent_noGroupedQueryReferencingChannel_doesNotAdjust() throws {
+ try database.writeSynchronously { session in
+ try session.mergeCurrentUserUnreadChannelCountsByGroup(["new": 5, "current": 3, "all": 8])
+ // Intentionally do not link the channel to any grouped query.
+ }
+
+ let event = try channelUpdatedEvent(group: "current")
+ try database.writeSynchronously { session in
+ _ = self.middleware.handle(event: event, session: session)
+ }
+
+ XCTAssertEqual(
+ ["new": 5, "current": 3, "all": 8],
+ database.viewContext.currentUser?.unreadChannelCountsByGroup
+ )
+ }
+
+ func test_channelUpdatedEvent_unreadCountsByGroupNotPopulated_doesNotAdjust() throws {
+ try database.writeSynchronously { session in
+ self.linkChannelToGroupedQueries(["new", "all"], session: session)
+ // Intentionally do not call mergeCurrentUserUnreadChannelCountsByGroup.
+ }
+
+ let event = try channelUpdatedEvent(group: "current")
+ try database.writeSynchronously { session in
+ _ = self.middleware.handle(event: event, session: session)
+ }
+
+ XCTAssertNil(database.viewContext.currentUser?.unreadChannelCountsByGroup)
+ }
+
+ func test_channelUpdatedEvent_newGroupIsAll_onlyDecrementsOld() throws {
+ try database.writeSynchronously { session in
+ try session.mergeCurrentUserUnreadChannelCountsByGroup(["new": 5, "all": 8])
+ self.linkChannelToGroupedQueries(["new", "all"], session: session)
+ }
+
+ let event = try channelUpdatedEvent(group: "all")
+ try database.writeSynchronously { session in
+ _ = self.middleware.handle(event: event, session: session)
+ }
+
+ // "new" decrements; "all" is intentionally never adjusted directly.
+ XCTAssertEqual(
+ ["new": 4, "all": 8],
+ database.viewContext.currentUser?.unreadChannelCountsByGroup
+ )
+ }
+
+ private func channelUpdatedEvent(group: String?) throws -> ChannelUpdatedEventDTO {
+ var extraData: [String: RawJSON] = [:]
+ if let group {
+ extraData[GroupedChannelKey.group] = .string(group)
+ }
+ let updatedChannel = ChannelDetailPayload.dummy(
+ cid: channelPayload.channel.cid,
+ extraData: extraData
+ )
+ return try ChannelUpdatedEventDTO(from: EventPayload(
+ eventType: .channelUpdated,
+ cid: channelPayload.channel.cid,
+ user: anotherUserPayload,
+ channel: updatedChannel,
+ createdAt: .unique
+ ))
+ }
+
+ private func linkChannelToGroupedQueries(_ groupKeys: [String], session: DatabaseSession) {
+ for key in groupKeys {
+ let queryDTO = session.saveQuery(query: ChannelListQuery(groupKey: key))
+ if let channelDTO = session.channel(cid: channelPayload.channel.cid) {
+ queryDTO.channels.insert(channelDTO)
+ }
+ }
+ }
+
private func newMessageEvent(type: MessageType) throws -> MessageNewEventDTO {
let regularMessage: MessagePayload = .dummy(
type: type,
diff --git a/Tests/StreamChatTests/WebSocketClient/Events/MessageEvents_Tests.swift b/Tests/StreamChatTests/WebSocketClient/Events/MessageEvents_Tests.swift
index f3f35d28f70..1a2027ed8a8 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_includesUnreadChannelCountsByGroup() throws {
+ let unreadChannelCountsByGroup: [String: Int] = [
+ "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),
+ unreadChannelCountsByGroup: unreadChannelCountsByGroup,
+ 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.unreadChannelCountsByGroup, unreadChannelCountsByGroup)
+ }
+
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..220187b834b 100644
--- a/Tests/StreamChatTests/WebSocketClient/Events/NotificationEvents_Tests.swift
+++ b/Tests/StreamChatTests/WebSocketClient/Events/NotificationEvents_Tests.swift
@@ -54,6 +54,65 @@ final class NotificationsEvents_Tests: XCTestCase {
XCTAssertEqual(event?.unreadCount, .init(channels: 8, messages: 55, threads: 10))
}
+ func test_markRead_decodesUnreadChannelCountsByGroup() throws {
+ let json = """
+ {
+ "type": "notification.mark_read",
+ "cid": "messaging:general",
+ "channel_type": "messaging",
+ "channel_id": "general",
+ "channel": {
+ "id": "general",
+ "type": "messaging",
+ "cid": "messaging:general",
+ "created_at": "2020-07-21T14:47:57Z",
+ "updated_at": "2020-07-21T14:47:57Z",
+ "frozen": false,
+ "disabled": false,
+ "config": {
+ "created_at": "2020-07-21T14:47:57Z",
+ "updated_at": "2020-07-21T14:47:57Z",
+ "reactions": true,
+ "typing_events": true,
+ "read_events": true,
+ "connect_events": true,
+ "uploads": true,
+ "replies": true,
+ "quotes": true,
+ "search": false,
+ "mutes": true,
+ "url_enrichment": true,
+ "message_retention": "infinite",
+ "max_message_length": 5000,
+ "commands": []
+ }
+ },
+ "user": {
+ "id": "steep-moon-9",
+ "role": "user",
+ "created_at": "2020-07-21T14:47:57Z",
+ "updated_at": "2020-07-21T14:47:57Z",
+ "last_active": "2020-07-21T14:47:57Z",
+ "online": true,
+ "banned": false
+ },
+ "created_at": "2020-07-21T14:47:57Z",
+ "unread_channels": 8,
+ "total_unread_count": 55,
+ "grouped_unread_channels": {
+ "direct": 2,
+ "vip": 5
+ }
+ }
+ """.data(using: .utf8)!
+
+ let event = try eventDecoder.decode(from: json) as? NotificationMarkReadEventDTO
+ let unreadChannelCountsByGroup = try XCTUnwrap(event?.payload.unreadChannelCountsByGroup)
+ XCTAssertEqual(unreadChannelCountsByGroup["direct"], 2)
+ XCTAssertEqual(unreadChannelCountsByGroup["vip"], 5)
+ XCTAssertEqual(unreadChannelCountsByGroup.count, 2)
+ }
+
func test_markUnread() throws {
let json = XCTestCase.mockData(fromJSONFile: "NotificationMarkUnread")
let event = try eventDecoder.decode(from: json) as? NotificationMarkUnreadEventDTO
@@ -200,11 +259,13 @@ final class NotificationsEvents_Tests: XCTestCase {
let session = DatabaseContainer_Spy(kind: .inMemory).viewContext
// Create event payload
+ let unreadChannelCountsByGroup: [String: Int] = ["direct": 4, "support": 1]
let eventPayload = EventPayload(
eventType: .notificationMarkRead,
cid: .unique,
user: .dummy(userId: .unique),
unreadCount: .init(channels: .unique, messages: .unique, threads: .unique),
+ unreadChannelCountsByGroup: unreadChannelCountsByGroup,
createdAt: .unique,
lastReadMessageId: "lastRead"
)
@@ -218,12 +279,14 @@ final class NotificationsEvents_Tests: XCTestCase {
// Save event to database
try session.saveUser(payload: eventPayload.user!)
_ = try session.saveCurrentUser(payload: .dummy(userPayload: .dummy(userId: .unique), unreadCount: eventPayload.unreadCount))
+ try session.saveEvent(payload: eventPayload)
// Assert event can be created and has correct fields
let event = try XCTUnwrap(dto.toDomainEvent(session: session) as? NotificationMarkReadEvent)
XCTAssertEqual(event.user.id, eventPayload.user?.id)
XCTAssertEqual(event.cid, eventPayload.cid)
XCTAssert(event.unreadCount?.isEqual(toPayload: eventPayload.unreadCount) == true)
+ XCTAssertEqual(event.unreadChannelCountsByGroup, unreadChannelCountsByGroup)
XCTAssertEqual(event.lastReadMessageId, eventPayload.lastReadMessageId)
XCTAssertEqual(event.createdAt, eventPayload.createdAt)
}
@@ -234,11 +297,13 @@ final class NotificationsEvents_Tests: XCTestCase {
let lastReadAt = Date()
// Create event payload
+ let unreadChannelCountsByGroup: [String: Int] = ["mentions": 2, "team": 6]
let eventPayload = EventPayload(
eventType: .notificationMarkRead,
cid: .unique,
user: .dummy(userId: .unique),
unreadCount: .init(channels: .unique, messages: .unique, threads: .unique),
+ unreadChannelCountsByGroup: unreadChannelCountsByGroup,
createdAt: .unique,
firstUnreadMessageId: "Hello",
lastReadAt: lastReadAt,
@@ -255,6 +320,7 @@ final class NotificationsEvents_Tests: XCTestCase {
// Save event to database
try session.saveUser(payload: eventPayload.user!)
_ = try session.saveCurrentUser(payload: .dummy(userPayload: .dummy(userId: .unique), unreadCount: eventPayload.unreadCount))
+ try session.saveEvent(payload: eventPayload)
// Assert event can be created and has correct fields
let event = try XCTUnwrap(dto.toDomainEvent(session: session) as? NotificationMarkUnreadEvent)
@@ -264,6 +330,7 @@ final class NotificationsEvents_Tests: XCTestCase {
XCTAssertEqual(event.firstUnreadMessageId, eventPayload.firstUnreadMessageId)
XCTAssertEqual(event.lastReadAt, eventPayload.lastReadAt)
XCTAssertEqual(event.lastReadMessageId, eventPayload.lastReadMessageId)
+ XCTAssertEqual(event.unreadChannelCountsByGroup, unreadChannelCountsByGroup)
XCTAssertEqual(event.unreadMessagesCount, eventPayload.unreadMessagesCount)
}
@@ -498,13 +565,17 @@ final class NotificationsEvents_Tests: XCTestCase {
let session = DatabaseContainer_Spy(kind: .inMemory).viewContext
// Create event payload
+ let unreadChannelCountsByGroup: [String: Int] = ["deleted": 8]
let eventPayload = EventPayload(
eventType: .notificationChannelDeleted,
cid: .unique,
channel: .dummy(cid: .unique),
+ unreadChannelCountsByGroup: unreadChannelCountsByGroup,
createdAt: .unique
)
+ _ = try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .admin))
+ try session.saveEvent(payload: eventPayload)
// Save event to database
_ = try session.saveChannel(payload: eventPayload.channel!, query: nil, cache: nil)
@@ -515,5 +586,6 @@ final class NotificationsEvents_Tests: XCTestCase {
let event = try XCTUnwrap(dto.toDomainEvent(session: session) as? NotificationChannelDeletedEvent)
XCTAssertEqual(event.cid, eventPayload.cid)
XCTAssertEqual(event.createdAt, eventPayload.createdAt)
+ XCTAssertEqual(event.unreadChannelCountsByGroup, unreadChannelCountsByGroup)
}
}
diff --git a/Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift b/Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift
index 833b5fe1991..99ccc1a2b7e 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 }))
@@ -447,9 +437,444 @@ final class ChannelListUpdater_Tests: XCTestCase {
XCTAssertFalse(channelsInQuery.contains(channel))
}
+ // MARK: - queryGroupedChannels
+
+ func test_queryGroupedChannels_initial_sendsBodyWithoutGroupsKey() throws {
+ listUpdater.queryGroupedChannels(groups: nil, limit: 10, watch: false, presence: false, completion: { _ in })
+
+ let body = try XCTUnwrap(apiClient.request_endpoint?.bodyAsDictionary())
+ XCTAssertEqual(10, body["limit"] as? Int)
+ XCTAssertNil(body["groups"])
+ }
+
+ func test_queryGroupedChannels_paginated_sendsBodyWithGroupsKeyAndCursor() throws {
+ let groupRequests = ["old": GroupedQueryChannelsRequestGroup(limit: 5, next: "old-cursor")]
+ listUpdater.queryGroupedChannels(
+ groups: groupRequests,
+ limit: nil,
+ watch: false,
+ presence: false,
+ completion: { _ in }
+ )
+
+ let body = try XCTUnwrap(apiClient.request_endpoint?.bodyAsDictionary())
+ XCTAssertNil(body["limit"], "top-level limit must be omitted when paginating")
+ let payloadGroups = try XCTUnwrap(body["groups"] as? [String: [String: Any]])
+ XCTAssertEqual(["old"], payloadGroups.keys.sorted())
+ XCTAssertEqual(5, payloadGroups["old"]?["limit"] as? Int)
+ XCTAssertEqual("old-cursor", payloadGroups["old"]?["next"] as? String)
+ XCTAssertNil(payloadGroups["old"]?["prev"])
+ }
+
+ func test_queryGroupedChannels_response_populatesNextOnGroup() throws {
+ try database.writeSynchronously { session in
+ try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .user))
+ }
+ nonisolated(unsafe) var completionResult: Result<[ChannelGroup], Error>?
+ let exp = expectation(description: "completion called")
+ listUpdater.queryGroupedChannels(groups: nil, limit: nil, watch: false, presence: false) { result in
+ completionResult = result
+ exp.fulfill()
+ }
+
+ let groupPayload = GroupedQueryChannelsGroupPayload(
+ channels: [],
+ unreadChannels: 3,
+ next: "next-cursor"
+ )
+ let payload = GroupedQueryChannelsPayload(groups: ["current": groupPayload])
+ apiClient.test_simulateResponse(.success(payload))
+
+ waitForExpectations(timeout: defaultTimeout)
+ let group = try completionResult?.get().first { $0.groupKey == "current" }
+ XCTAssertEqual("next-cursor", group?.next)
+ }
+
+ func test_queryGroupedChannels_paginated_mergesUnreadChannelCountsByGroup() throws {
+ // Seed current user with unread counts for multiple groups.
+ let userId = UserId.unique
+ try database.writeSynchronously { session in
+ try session.saveCurrentUser(payload: .dummy(userId: userId, role: .user))
+ try session.mergeCurrentUserUnreadChannelCountsByGroup(["new": 5, "current": 10, "old": 2])
+ }
+
+ let groups = ["old": GroupedQueryChannelsRequestGroup(limit: nil, next: "cursor")]
+ nonisolated(unsafe) var completionCalled = false
+ listUpdater.queryGroupedChannels(groups: groups, limit: nil, watch: false, presence: false) { _ in
+ completionCalled = true
+ }
+
+ // Paginated response carries only "old" group.
+ let payload = GroupedQueryChannelsPayload(
+ groups: ["old": .init(channels: [], unreadChannels: 99)]
+ )
+ apiClient.test_simulateResponse(.success(payload))
+
+ AssertAsync.willBeTrue(completionCalled)
+
+ // "old" is refreshed from the payload; unrelated groups are left untouched.
+ let counters = try database.readSynchronously { $0.currentUser?.unreadChannelCountsByGroup ?? [:] }
+ XCTAssertEqual(5, counters["new"])
+ XCTAssertEqual(10, counters["current"])
+ XCTAssertEqual(99, counters["old"])
+ }
+
+ func test_queryGroupedChannels_initial_mergesIntoExistingUnreadChannelCountsByGroup() throws {
+ // Seed an unrelated group; the initial fetch should leave it intact while updating
+ // counts for the groups the payload covers.
+ let userId = UserId.unique
+ try database.writeSynchronously { session in
+ try session.saveCurrentUser(payload: .dummy(userId: userId, role: .user))
+ try session.mergeCurrentUserUnreadChannelCountsByGroup(["old": 99])
+ }
+
+ nonisolated(unsafe) var completionCalled = false
+ listUpdater.queryGroupedChannels(groups: nil, limit: nil, watch: false, presence: false) { _ in
+ completionCalled = true
+ }
+
+ let payload = GroupedQueryChannelsPayload(
+ groups: [
+ "new": .init(channels: [], unreadChannels: 5),
+ "current": .init(channels: [], unreadChannels: 10)
+ ]
+ )
+ apiClient.test_simulateResponse(.success(payload))
+
+ AssertAsync.willBeTrue(completionCalled)
+
+ let counters = try database.readSynchronously { $0.currentUser?.unreadChannelCountsByGroup ?? [:] }
+ XCTAssertEqual(5, counters["new"])
+ XCTAssertEqual(10, counters["current"])
+ XCTAssertEqual(99, counters["old"])
+ }
+
+ func test_queryGroupedChannels_initial_linksChannelsToQueryDTOPerGroupKey() throws {
+ try database.writeSynchronously { session in
+ try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .user))
+ }
+ let allCid1 = ChannelId(type: .messaging, id: .unique)
+ let allCid2 = ChannelId(type: .messaging, id: .unique)
+ let newCid = ChannelId(type: .messaging, id: .unique)
+ let allChannels = [self.dummyPayload(with: allCid1), self.dummyPayload(with: allCid2)]
+ let newChannels = [self.dummyPayload(with: newCid)]
+
+ let exp = expectation(description: "completion called")
+ listUpdater.queryGroupedChannels(groups: nil, limit: nil, watch: false, presence: false) { _ in exp.fulfill() }
+ let payload = GroupedQueryChannelsPayload(
+ groups: [
+ "all": .init(channels: allChannels, unreadChannels: 0),
+ "new": .init(channels: newChannels, unreadChannels: 0)
+ ]
+ )
+ apiClient.test_simulateResponse(.success(payload))
+ waitForExpectations(timeout: defaultTimeout)
+
+ let allLinked = try XCTUnwrap(database.viewContext.channelListQuery(ChannelListQuery(groupKey: "all")))
+ let newLinked = try XCTUnwrap(database.viewContext.channelListQuery(ChannelListQuery(groupKey: "new")))
+ XCTAssertEqual(Set([allCid1.rawValue, allCid2.rawValue]), Set(allLinked.channels.map(\.cid)))
+ XCTAssertEqual(Set([newCid.rawValue]), Set(newLinked.channels.map(\.cid)))
+ }
+
+ func test_queryGroupedChannels_initial_resetsChannelsForAllPreSeededGroups() throws {
+ let userId = UserId.unique
+ let staleAllCid = ChannelId(type: .messaging, id: .unique)
+ let staleNewCid = ChannelId(type: .messaging, id: .unique)
+ let freshAllCid = ChannelId(type: .messaging, id: .unique)
+ let freshNewCid = ChannelId(type: .messaging, id: .unique)
+
+ try database.writeSynchronously { [self] session in
+ try session.saveCurrentUser(payload: .dummy(userId: userId, role: .user))
+ let allQuery = session.saveQuery(query: ChannelListQuery(groupKey: "all"))
+ let newQuery = session.saveQuery(query: ChannelListQuery(groupKey: "new"))
+ let staleAll = try session.saveChannel(payload: dummyPayload(with: staleAllCid))
+ let staleNew = try session.saveChannel(payload: dummyPayload(with: staleNewCid))
+ allQuery.channels.insert(staleAll)
+ newQuery.channels.insert(staleNew)
+ }
+
+ let exp = expectation(description: "completion called")
+ listUpdater.queryGroupedChannels(groups: nil, limit: nil, watch: false, presence: false) { _ in exp.fulfill() }
+ let payload = GroupedQueryChannelsPayload(
+ groups: [
+ "all": .init(channels: [dummyPayload(with: freshAllCid)], unreadChannels: 0),
+ "new": .init(channels: [dummyPayload(with: freshNewCid)], unreadChannels: 0)
+ ]
+ )
+ apiClient.test_simulateResponse(.success(payload))
+ waitForExpectations(timeout: defaultTimeout)
+
+ let (allCids, newCids) = try database.readSynchronously { session -> (Set, Set) in
+ let allLinked = try XCTUnwrap(session.channelListQuery(ChannelListQuery(groupKey: "all")))
+ let newLinked = try XCTUnwrap(session.channelListQuery(ChannelListQuery(groupKey: "new")))
+ return (Set(allLinked.channels.map(\.cid)), Set(newLinked.channels.map(\.cid)))
+ }
+ XCTAssertEqual(Set([freshAllCid.rawValue]), allCids)
+ XCTAssertEqual(Set([freshNewCid.rawValue]), newCids)
+ }
+
+ func test_queryGroupedChannels_initialFetchForSingleGroup_resetsAndLinks() throws {
+ try database.writeSynchronously { session in
+ try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .user))
+ }
+ let staleCid = ChannelId(type: .messaging, id: .unique)
+ try database.writeSynchronously { session in
+ let staleDTO = try session.saveChannel(payload: self.dummyPayload(with: staleCid))
+ let queryDTO = session.saveQuery(query: ChannelListQuery(groupKey: "all"))
+ queryDTO.channels.insert(staleDTO)
+ }
+ let freshCid = ChannelId(type: .messaging, id: .unique)
+ let freshChannels = [self.dummyPayload(with: freshCid)]
+
+ let groups = ["all": GroupedQueryChannelsRequestGroup(limit: nil, next: nil)]
+ let exp = expectation(description: "completion called")
+ listUpdater.queryGroupedChannels(groups: groups, limit: nil, watch: false, presence: false) { _ in exp.fulfill() }
+ let payload = GroupedQueryChannelsPayload(
+ groups: ["all": .init(channels: freshChannels, unreadChannels: 0)]
+ )
+ apiClient.test_simulateResponse(.success(payload))
+ waitForExpectations(timeout: defaultTimeout)
+
+ let linked = try XCTUnwrap(database.viewContext.channelListQuery(ChannelListQuery(groupKey: "all")))
+ XCTAssertEqual(Set([freshCid.rawValue]), Set(linked.channels.map(\.cid)))
+ XCTAssertNil(linked.next)
+ }
+
+ func test_queryGroupedChannels_persistsNextCursorOnQueryDTO() throws {
+ try database.writeSynchronously { session in
+ try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .user))
+ }
+ let exp = expectation(description: "completion called")
+ listUpdater.queryGroupedChannels(groups: nil, limit: nil, watch: false, presence: false) { _ in exp.fulfill() }
+ let payload = GroupedQueryChannelsPayload(
+ groups: [
+ "all": .init(channels: [], unreadChannels: 0, next: "all-next", prev: nil),
+ "exhausted": .init(channels: [], unreadChannels: 0, next: nil, prev: nil)
+ ]
+ )
+ apiClient.test_simulateResponse(.success(payload))
+ waitForExpectations(timeout: defaultTimeout)
+
+ let allLinked = try XCTUnwrap(database.viewContext.channelListQuery(ChannelListQuery(groupKey: "all")))
+ let exhaustedLinked = try XCTUnwrap(database.viewContext.channelListQuery(ChannelListQuery(groupKey: "exhausted")))
+ XCTAssertEqual("all-next", allLinked.next)
+ XCTAssertNil(exhaustedLinked.next)
+ }
+
+ func test_queryGroupedChannels_persistsWatchAndPresenceOnQueryDTO() throws {
+ try database.writeSynchronously { session in
+ try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .user))
+ }
+ let exp = expectation(description: "completion called")
+ listUpdater.queryGroupedChannels(groups: nil, limit: nil, watch: true, presence: true) { _ in exp.fulfill() }
+ let payload = GroupedQueryChannelsPayload(
+ groups: [
+ "all": .init(channels: [], unreadChannels: 0, next: nil, prev: nil),
+ "current": .init(channels: [], unreadChannels: 0, next: nil, prev: nil)
+ ]
+ )
+ apiClient.test_simulateResponse(.success(payload))
+ waitForExpectations(timeout: defaultTimeout)
+
+ let allLinked = try XCTUnwrap(database.viewContext.channelListQuery(ChannelListQuery(groupKey: "all")))
+ let currentLinked = try XCTUnwrap(database.viewContext.channelListQuery(ChannelListQuery(groupKey: "current")))
+ XCTAssertTrue(allLinked.watch)
+ XCTAssertTrue(allLinked.presence)
+ XCTAssertTrue(currentLinked.watch)
+ XCTAssertTrue(currentLinked.presence)
+ }
+
+ func test_queryGroupedChannels_overwritesWatchAndPresenceOnSubsequentCalls() throws {
+ try database.writeSynchronously { session in
+ try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .user))
+ }
+ // First call with both flags true.
+ let firstExp = expectation(description: "first completion called")
+ listUpdater.queryGroupedChannels(groups: nil, limit: nil, watch: true, presence: true) { _ in firstExp.fulfill() }
+ let firstPayload = GroupedQueryChannelsPayload(
+ groups: ["all": .init(channels: [], unreadChannels: 0, next: nil, prev: nil)]
+ )
+ apiClient.test_simulateResponse(.success(firstPayload))
+ wait(for: [firstExp], timeout: defaultTimeout)
+
+ // Second call with both flags false should overwrite.
+ apiClient.cleanUp()
+ let secondExp = expectation(description: "second completion called")
+ listUpdater.queryGroupedChannels(groups: nil, limit: nil, watch: false, presence: false) { _ in secondExp.fulfill() }
+ apiClient.test_simulateResponse(.success(firstPayload))
+ wait(for: [secondExp], timeout: defaultTimeout)
+
+ let linked = try XCTUnwrap(database.viewContext.channelListQuery(ChannelListQuery(groupKey: "all")))
+ XCTAssertFalse(linked.watch)
+ XCTAssertFalse(linked.presence)
+ }
+
+ // MARK: - paginationState
+
+ func test_paginationState_returnsPersistedNextWatchAndPresence() async throws {
+ try await database.write { session in
+ let queryDTO = session.saveQuery(query: ChannelListQuery(groupKey: "all"))
+ queryDTO.next = "cursor-1"
+ queryDTO.watch = true
+ queryDTO.presence = true
+ }
+ let state = try await listUpdater.paginationState(for: "all")
+ XCTAssertEqual("cursor-1", state.next)
+ XCTAssertEqual(true, state.watch)
+ XCTAssertEqual(true, state.presence)
+ }
+
+ func test_paginationState_unknownGroup_returnsEmpty() async throws {
+ let state = try await listUpdater.paginationState(for: "never-saved")
+ XCTAssertNil(state.next)
+ XCTAssertNil(state.watch)
+ XCTAssertNil(state.presence)
+ }
+
+ func test_queryGroupedChannels_specificGroups_sendsPerGroupBody() throws {
+ let groups = [
+ "new": GroupedQueryChannelsRequestGroup(limit: 5, next: nil),
+ "current": GroupedQueryChannelsRequestGroup(limit: 5, next: nil)
+ ]
+ listUpdater.queryGroupedChannels(
+ groups: groups,
+ limit: nil,
+ watch: false,
+ presence: false,
+ completion: { _ in }
+ )
+
+ let body = try XCTUnwrap(apiClient.request_endpoint?.bodyAsDictionary())
+ XCTAssertNil(body["limit"], "Top-level limit must be omitted when groups are specified")
+ let payloadGroups = try XCTUnwrap(body["groups"] as? [String: [String: Any]])
+ XCTAssertEqual(["current", "new"], payloadGroups.keys.sorted())
+ XCTAssertEqual(5, payloadGroups["new"]?["limit"] as? Int)
+ XCTAssertEqual(5, payloadGroups["current"]?["limit"] as? Int)
+ XCTAssertNil(payloadGroups["new"]?["next"])
+ XCTAssertNil(payloadGroups["current"]?["next"])
+ }
+
+ func test_queryGroupedChannels_specificGroups_resetsChannelsForFreshFetchOnly() throws {
+ try database.writeSynchronously { session in
+ try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .user))
+ }
+ // Seed both groups with a stale channel each, plus a "current" paginated query DTO with a cursor.
+ let staleNewCid = ChannelId(type: .messaging, id: .unique)
+ let staleCurrentCid = ChannelId(type: .messaging, id: .unique)
+ try database.writeSynchronously { session in
+ let newDTO = try session.saveChannel(payload: self.dummyPayload(with: staleNewCid))
+ let newQueryDTO = session.saveQuery(query: ChannelListQuery(groupKey: "new"))
+ newQueryDTO.channels.insert(newDTO)
+ let currentDTO = try session.saveChannel(payload: self.dummyPayload(with: staleCurrentCid))
+ let currentQueryDTO = session.saveQuery(query: ChannelListQuery(groupKey: "current"))
+ currentQueryDTO.channels.insert(currentDTO)
+ }
+ let freshNewCid = ChannelId(type: .messaging, id: .unique)
+ let freshCurrentCid = ChannelId(type: .messaging, id: .unique)
+
+ // "new" → fresh fetch (next: nil), should reset. "current" → continuation (next: "cursor"), should append.
+ let groups = [
+ "new": GroupedQueryChannelsRequestGroup(limit: nil, next: nil),
+ "current": GroupedQueryChannelsRequestGroup(limit: nil, next: "cursor")
+ ]
+ let exp = expectation(description: "completion called")
+ listUpdater.queryGroupedChannels(groups: groups, limit: nil, watch: false, presence: false) { _ in exp.fulfill() }
+ let payload = GroupedQueryChannelsPayload(
+ groups: [
+ "new": .init(channels: [self.dummyPayload(with: freshNewCid)], unreadChannels: 0),
+ "current": .init(channels: [self.dummyPayload(with: freshCurrentCid)], unreadChannels: 0)
+ ]
+ )
+ apiClient.test_simulateResponse(.success(payload))
+ waitForExpectations(timeout: defaultTimeout)
+
+ let newLinked = try XCTUnwrap(database.viewContext.channelListQuery(ChannelListQuery(groupKey: "new")))
+ let currentLinked = try XCTUnwrap(database.viewContext.channelListQuery(ChannelListQuery(groupKey: "current")))
+ XCTAssertEqual(Set([freshNewCid.rawValue]), Set(newLinked.channels.map(\.cid)))
+ XCTAssertEqual(Set([staleCurrentCid.rawValue, freshCurrentCid.rawValue]), Set(currentLinked.channels.map(\.cid)))
+ }
+
+ func test_queryGroupedChannels_paginatedContinuation_appendsToQueryDTOWithoutReset() throws {
+ try database.writeSynchronously { session in
+ try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .user))
+ }
+ let existingCid = ChannelId(type: .messaging, id: .unique)
+ try database.writeSynchronously { session in
+ let existingDTO = try session.saveChannel(payload: self.dummyPayload(with: existingCid))
+ let queryDTO = session.saveQuery(query: ChannelListQuery(groupKey: "all"))
+ queryDTO.channels.insert(existingDTO)
+ }
+ let appendedCid = ChannelId(type: .messaging, id: .unique)
+ let appendedChannels = [self.dummyPayload(with: appendedCid)]
+
+ let groups = ["all": GroupedQueryChannelsRequestGroup(limit: nil, next: "cursor-1")]
+ let exp = expectation(description: "completion called")
+ listUpdater.queryGroupedChannels(groups: groups, limit: nil, watch: false, presence: false) { _ in exp.fulfill() }
+ let payload = GroupedQueryChannelsPayload(
+ groups: ["all": .init(channels: appendedChannels, unreadChannels: 0)]
+ )
+ apiClient.test_simulateResponse(.success(payload))
+ waitForExpectations(timeout: defaultTimeout)
+
+ let linked = try XCTUnwrap(database.viewContext.channelListQuery(ChannelListQuery(groupKey: "all")))
+ XCTAssertEqual(Set([existingCid.rawValue, appendedCid.rawValue]), Set(linked.channels.map(\.cid)))
+ }
+
private func channels(for query: ChannelListQuery, database: DatabaseContainer) -> Set {
let request = NSFetchRequest(entityName: ChannelListQueryDTO.entityName)
- request.predicate = NSPredicate(format: "filterHash == %@", query.filter.filterHash)
+ request.predicate = NSPredicate(format: "filterHash == %@", query.queryHash)
return (try? database.viewContext.fetch(request).first)?.channels ?? Set()
}
+
+ // MARK: - Update Predefined Filter
+
+ func test_update_predefinedFilterPayload_returnsQueryWithDecodedFilterAndSort() throws {
+ let query = ChannelListQuery(
+ predefinedFilter: "user_per_channel_type_channels",
+ filterValues: ["user_id": "r2-d2"]
+ )
+ let payload = PredefinedFilterPayload(
+ name: "user_per_channel_type_channels",
+ filter: ["type": .string("messaging")],
+ sort: [["field": .string("last_message_at"), "direction": .number(-1)]]
+ )
+ let response = ChannelListPayload(
+ channels: [],
+ predefinedFilter: payload
+ )
+ let expectation = expectation(description: "update completes")
+ nonisolated(unsafe) var captured: Result?
+
+ listUpdater.update(channelListQuery: query) { result in
+ captured = result
+ expectation.fulfill()
+ }
+ apiClient.test_simulateResponse(.success(response))
+ wait(for: [expectation], timeout: defaultTimeout)
+
+ let result = try XCTUnwrap(captured).get()
+ let updated = try XCTUnwrap(result.updatedQuery)
+ XCTAssertEqual(updated.filter.key, "type")
+ XCTAssertEqual(updated.filter.value as? String, "messaging")
+ XCTAssertEqual(updated.sort.count, 1)
+ XCTAssertEqual(updated.sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey)
+ XCTAssertEqual(updated.sort.first?.direction, -1)
+ }
+
+ func test_update_whenResolvedQueryDoesNotChange_returnsNilQuery() throws {
+ let query = ChannelListQuery(filter: .in(.members, values: [.unique]))
+ let response = ChannelListPayload(channels: [])
+ let expectation = expectation(description: "update completes")
+ nonisolated(unsafe) var captured: Result?
+
+ listUpdater.update(channelListQuery: query) { result in
+ captured = result
+ expectation.fulfill()
+ }
+ apiClient.test_simulateResponse(.success(response))
+ wait(for: [expectation], timeout: defaultTimeout)
+
+ let result = try XCTUnwrap(captured).get()
+ XCTAssertNil(result.updatedQuery)
+ }
}
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index dc72fa74152..61f6fb289cf 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -725,6 +725,20 @@ lane :copyright do
)
end
+desc 'Regenerate Sourcery code (predefined channel-list filter/sort mappings)'
+lane :generate do
+ Dir.chdir('..') { sh('make generate') }
+end
+
+desc 'Fail if generated code is stale (run `make generate` and commit the result)'
+lane :validate_generated_code do
+ generate
+ Dir.chdir('..') do
+ changed = sh('git status --porcelain Sources/StreamChat/Generated/PredefinedFilter+Generated.swift', log: false).strip
+ UI.user_error!('Generated code is stale. Run `make generate` and commit the result.') unless changed.empty?
+ end
+end
+
lane :validate_public_interface do
next unless is_check_required(sources: sources_matrix[:public_interface], force_check: @force_check)