Grouped channels endpoint (v5)#4076
Conversation
…sent (avoids manually keeping the message count up to date which is part of the count_messages app setting)
…ersist using it
- Add internal ChannelListQuery.groupKey and queryHash (groupKey ?? filter.filterHash);
ChannelListQueryDTO now uses this stable identity so date-bearing filters from the
grouped endpoint don't churn filterHash every second.
- Rename channelListQuery(filterHash:) -> channelListQuery(_:) since every caller had
the full ChannelListQuery in scope anyway.
- Rename GroupedChannelsGroup.group -> groupKey for naming symmetry.
- prefill(group:) sets query.groupKey before worker.prefill; drop the redundant
ChatChannelListController.prefilledGroupKey in favor of query.groupKey as the single
source of truth.
- SyncRepository routes prefilled controllers through queryGroupedChannels and
SyncGroupedChannelsOperation forwards each group back to the matching controller's
prefill(group:) using query.groupKey.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds grouped-channel querying and observation: endpoint and payloads, ChatClient API and factory, ChannelGroup model and per-group unread counts persisted in Core Data, state-layer group-aware pagination/linking, sync batching for grouped lists, event propagation for grouped unread counts, and extensive tests and mocks. ChangesGrouped Channels Listing & Unread Counts
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Suggested labels
Suggested reviewers
✨ Finishing Touches🧪 Generate unit tests (beta)
|
StreamChatUI XCSize
|
martinmitrevski
left a comment
There was a problem hiding this comment.
Looks good in general! Need to test a bit, also left some comments, let me know what you think.
| import Foundation | ||
|
|
||
| /// A grouped channels response returned by `ChatClient.queryGroupedChannels`. | ||
| public struct GroupedChannels: Sendable { |
There was a problem hiding this comment.
shouldn't we do final classes instead?
There was a problem hiding this comment.
For public data types we still do struct, so for consistency reasons I think it is better to follow the same pattern
| public let groupKey: String | ||
|
|
||
| /// The channels that belong to this group. | ||
| public let channels: [ChatChannel] |
There was a problem hiding this comment.
feels strange to return this as part of the current user, wdyt?
There was a problem hiding this comment.
Yeah, now we should drop these and the way for reading channels is through ChannelList
| let groupedChannels = try await channelListUpdater.queryGroupedChannels( | ||
| groupPagination: .init(groupKey: groupKey, next: pagination.cursor), | ||
| limit: pagination.pageSize, | ||
| watch: true, |
There was a problem hiding this comment.
Why's this hardcoded to true? Afaik, it should be false, and we rely on membership events. Or this changed in the meantime?
There was a problem hiding this comment.
Changed it everywhere back to false
There was a problem hiding this comment.
I assume backend confirmed this, right?
| extension ChannelListState { | ||
| final class Observer { | ||
| private let channelListObserver: StateLayerDatabaseObserver<ListResult, ChatChannel, ChannelDTO> | ||
| @MainActor final class Observer { |
There was a problem hiding this comment.
should this be on the main actor?
| worker.unlink(channel: channel, with: query) { _ in | ||
| completion?() | ||
| } | ||
| guard exists else { return } |
There was a problem hiding this comment.
we're not calling completion here, while we did that before. Is that expected?
There was a problem hiding this comment.
This is expected, it was a bit off before where same event could do both unlink and link (only one action would actually do something)
| .trimmingCharacters(in: .whitespacesAndNewlines) | ||
| .lowercased() | ||
| if let currentGroupKey, !currentGroupKey.isEmpty { | ||
| return groupKey == currentGroupKey || groupKey == "all" ? .link : .unlink |
There was a problem hiding this comment.
this check is not ideal, are we sure it's always going to be called "all"? But not sure if there's better way, we don't have any clue about the filter.
|
|
||
| if clientConfig.isChannelAutomaticFilteringEnabled { | ||
| // When auto-filtering is enabled the channel will appear or not automatically if the | ||
| // query matches the DB Predicate. So here we default to saying it always belong to the current query. |
There was a problem hiding this comment.
not sure I fully understand - does that mean that in this case, we delegate the decision to the db predicate?
There was a problem hiding this comment.
I added some extensive documentation above
…in the middleware
SDK Performance
|
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift (1)
39-57:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winReset all newly tracked call counters in
cleanUp().Line 54-Line 57 clears related spy state but leaves
startWatchingChannels_callCount,link_callCount, andunlink_callCountunchanged, which can leak state across tests.Suggested patch
func cleanUp() { update_queries.removeAll() update_completion = nil update_completion_result = nil fetch_queries.removeAll() fetch_completion = nil queryGroupedChannels_callCount = 0 queryGroupedChannels_paginations.removeAll() queryGroupedChannels_watchValues.removeAll() queryGroupedChannels_presenceValues.removeAll() queryGroupedChannels_result = nil markAllRead_completion = nil + startWatchingChannels_callCount = 0 startWatchingChannels_cids.removeAll() startWatchingChannels_completion = nil startWatchingChannels_completion_success = false + link_callCount = 0 + unlink_callCount = 0 }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift` around lines 39 - 57, The cleanUp() method in ChannelListUpdater_Spy doesn't reset three tracked call counters which can leak state between tests; update cleanUp() to also reset startWatchingChannels_callCount, link_callCount, and unlink_callCount to their initial values (likely 0) so all spy counters are cleared when cleanUp() runs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@CHANGELOG.md`:
- Around line 6-12: Update the CHANGELOG.md upcoming StreamChat entry to
reference the correct symbol name: replace the incorrect
`unreadChannelCountsByGroup` with `groupedUnreadCount` (e.g., mention
`groupedUnreadCount` on `CurrentChatUser` and any related API like
`ChatClient.queryGroupedChannels` / `ChatClient.makeChannelList`). Also add the
required `StreamChatUI` and `StreamChatCommonUI` subsections under the `#
Upcoming` header with brief placeholders or notes so all three components
(StreamChat, StreamChatUI, StreamChatCommonUI) are present as per guidelines.
In `@Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift`:
- Around line 70-85: channelListQuery(_:) and saveQuery(query:) currently use
query.queryHash directly to load and persist ChannelListQueryDTO.filterHash,
causing collisions between normal and grouped queries; update both functions to
normalize the stored lookup key by distinguishing grouped queries (e.g., if
query.groupKey != nil then use a derived key like "group:\(query.groupKey)" or a
dedicated groupedHash) and ensure channelListQuery and saveQuery use the same
transformed key when fetching/inserting (reference
ChannelListQueryDTO.filterHash, query.queryHash, and query.groupKey). This keeps
grouped queries namespaced (or stored separately) so
channels/next/watch/presence state won't be mixed.
In `@Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift`:
- Around line 149-157: In saveCurrentUserUnreadChannelCountsByGroup, treat a
missing CurrentUserDTO as a cache miss rather than a hard failure: after calling
invalidateCurrentUserCache(), replace the guard that throws
ClientError.CurrentUserDoesNotExist() with a safe optional check so that if
currentUser is nil the method simply returns (no-op) instead of throwing; when
dto exists assign dto.unreadChannelCountsByGroup as before. This avoids aborting
writes from callers like queryGroupedChannels and event persistence when the
CurrentUserDTO hasn't been materialized yet.
In `@Sources/StreamChat/StateLayer/ChannelList.swift`:
- Around line 117-119: The code passes the sentinel page-size (.unsetPageSize)
through resolved into Pagination and the completion check; update the logic in
the load-more path (around resolved, loadChannels(with: Pagination(...)), and
setHasLoadedAllPreviousChannels(...)) to detect the sentinel and replace it with
a concrete fallback page size before constructing Pagination and before
comparing channels.count < resolved (e.g., compute let effectivePageSize =
(resolved == .unsetPageSize) ? <defaultPageSize> : resolved and use
effectivePageSize for Pagination(pageSize: ...) and for the completion check).
In `@Sources/StreamChat/Workers/ChannelListLinker.swift`:
- Around line 179-190: The grouping logic in linkingAction(for channel:
ChatChannel) treats a missing/empty
channel.extraData[GroupedChannelKey.extraData] as .none, which prevents
unlinking previously linked channels after a ChannelUpdatedEvent; change that
final fallback to return .unlink so empty or missing group values will remove
the channel from the grouped query. Keep the existing comparisons against
query.groupKey and GroupedChannelKey.all; only replace the "return .none" branch
with "return .unlink" in the linkingAction(for:) implementation.
In `@Sources/StreamChat/Workers/ChannelListUpdater.swift`:
- Around line 227-253: During a full grouped refresh (when request.groups == nil
/ isInitialFetch true) the current write only updates groups present in
payload.groups and leaves persisted ChannelListQueryDTOs for groups removed
server-side; modify the database.write(converting:) block in
ChannelListUpdater.swift to detect a full grouped refresh and remove or clear
any saved grouped queries not present in payload.groups: after building/updating
queryDTO for each payload.groups entry, enumerate existing saved group queries
via the session (e.g., saved ChannelListQuery/ChannelListQueryDTO entries),
compute the set difference with payload.groups.keys, and for each missing group
either delete the query DTO or clear its channels, next cursor, watch/presence
flags and unread counts so makeChannelList(with:) no longer surfaces stale data.
Ensure this logic runs only for full refreshes (isInitialFetch / request.groups
== nil) and reference queryDTO, saveQuery(query: ChannelListQuery), and
makeChannelList(with:) when locating where to apply the cleanup.
---
Outside diff comments:
In `@TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift`:
- Around line 39-57: The cleanUp() method in ChannelListUpdater_Spy doesn't
reset three tracked call counters which can leak state between tests; update
cleanUp() to also reset startWatchingChannels_callCount, link_callCount, and
unlink_callCount to their initial values (likely 0) so all spy counters are
cleared when cleanUp() runs.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 512bae13-b8d0-4d2a-a10a-b5ef743014b7
📒 Files selected for processing (47)
CHANGELOG.mdSources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swiftSources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swiftSources/StreamChat/APIClient/Endpoints/EndpointPath.swiftSources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swiftSources/StreamChat/ChatClient.swiftSources/StreamChat/Database/DTOs/ChannelDTO.swiftSources/StreamChat/Database/DTOs/ChannelListQueryDTO.swiftSources/StreamChat/Database/DTOs/CurrentUserDTO.swiftSources/StreamChat/Database/DatabaseSession.swiftSources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contentsSources/StreamChat/Models/ChannelGroup.swiftSources/StreamChat/Models/CurrentUser.swiftSources/StreamChat/Query/ChannelListQuery.swiftSources/StreamChat/Query/Filter+predicate.swiftSources/StreamChat/Query/Filter.swiftSources/StreamChat/Query/Pagination.swiftSources/StreamChat/Repositories/SyncOperations.swiftSources/StreamChat/Repositories/SyncRepository.swiftSources/StreamChat/StateLayer/ChannelList.swiftSources/StreamChat/StateLayer/ChannelListState.swiftSources/StreamChat/StateLayer/ChatClient+Factory.swiftSources/StreamChat/StateLayer/DatabaseObserver/StateLayerDatabaseObserver.swiftSources/StreamChat/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware.swiftSources/StreamChat/WebSocketClient/Events/ChannelEvents.swiftSources/StreamChat/WebSocketClient/Events/Event.swiftSources/StreamChat/WebSocketClient/Events/EventPayload.swiftSources/StreamChat/WebSocketClient/Events/MessageEvents.swiftSources/StreamChat/WebSocketClient/Events/NotificationEvents.swiftSources/StreamChat/Workers/ChannelListLinker.swiftSources/StreamChat/Workers/ChannelListUpdater.swiftTestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swiftTestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swiftTestTools/StreamChatTestTools/Mocks/StreamChat/State/ChannelList_Mock.swiftTestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swiftTests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swiftTests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swiftTests/StreamChatTests/ChatClient_Tests.swiftTests/StreamChatTests/Database/DTOs/CurrentUserDTO_Tests.swiftTests/StreamChatTests/Database/DatabaseSession_Tests.swiftTests/StreamChatTests/Query/Sorting/ListDatabaseObserver+Sorting_Tests.swiftTests/StreamChatTests/Repositories/SyncRepository_Tests.swiftTests/StreamChatTests/StateLayer/ChannelList_Tests.swiftTests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swiftTests/StreamChatTests/WebSocketClient/Events/MessageEvents_Tests.swiftTests/StreamChatTests/WebSocketClient/Events/NotificationEvents_Tests.swiftTests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift
| ## StreamChat | ||
| ### ✅ Added | ||
| - Add `ChatClient.queryGroupedChannels(limit:presence:watch:)` to fetch grouped channels with per-group unread counts [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076) | ||
| - Add `ChatClient.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) | ||
| ### 🔄 Changed | ||
|
|
There was a problem hiding this comment.
Fix the Upcoming changelog entry before merge.
This block documents unreadChannelCountsByGroup, but the feature in this PR is groupedUnreadCount, so the release notes will point users to a non-existent symbol. It also omits the required StreamChatUI and StreamChatCommonUI subsections under # Upcoming.
As per coding guidelines, Include separate subsections in CHANGELOG.md for StreamChat, StreamChatUI, and StreamChatCommonUI.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@CHANGELOG.md` around lines 6 - 12, Update the CHANGELOG.md upcoming
StreamChat entry to reference the correct symbol name: replace the incorrect
`unreadChannelCountsByGroup` with `groupedUnreadCount` (e.g., mention
`groupedUnreadCount` on `CurrentChatUser` and any related API like
`ChatClient.queryGroupedChannels` / `ChatClient.makeChannelList`). Also add the
required `StreamChatUI` and `StreamChatCommonUI` subsections under the `#
Upcoming` header with brief placeholders or notes so all three components
(StreamChat, StreamChatUI, StreamChatCommonUI) are present as per guidelines.
| func channelListQuery(_ query: ChannelListQuery) -> ChannelListQueryDTO? { | ||
| ChannelListQueryDTO.load(filterHash: query.queryHash, context: self) | ||
| } | ||
|
|
||
| func saveQuery(query: ChannelListQuery) -> ChannelListQueryDTO { | ||
| if let existingDTO = channelListQuery(filterHash: query.filter.filterHash) { | ||
| if let existingDTO = channelListQuery(query) { | ||
| return existingDTO | ||
| } | ||
|
|
||
| let request = ChannelListQueryDTO.fetchRequest( | ||
| keyPath: #keyPath(ChannelListQueryDTO.filterHash), | ||
| equalTo: query.filter.filterHash | ||
| equalTo: query.queryHash | ||
| ) | ||
| let newDTO = NSEntityDescription.insertNewObject(into: self, for: request) | ||
| newDTO.filterHash = query.filter.filterHash | ||
| newDTO.filterHash = query.queryHash | ||
|
|
There was a problem hiding this comment.
Namespace grouped query ids before persisting them.
These lookups now store both regular list queries and grouped list queries in the same unique filterHash column via query.queryHash. Since grouped queries use the raw groupKey, one collision aliases the DTO and mixes channels, next, watch, and presence state between unrelated lists. Prefix grouped hashes (for example, group:<key>) or persist them in a separate field.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift` around lines 70 -
85, channelListQuery(_:) and saveQuery(query:) currently use query.queryHash
directly to load and persist ChannelListQueryDTO.filterHash, causing collisions
between normal and grouped queries; update both functions to normalize the
stored lookup key by distinguishing grouped queries (e.g., if query.groupKey !=
nil then use a derived key like "group:\(query.groupKey)" or a dedicated
groupedHash) and ensure channelListQuery and saveQuery use the same transformed
key when fetching/inserting (reference ChannelListQueryDTO.filterHash,
query.queryHash, and query.groupKey). This keeps grouped queries namespaced (or
stored separately) so channels/next/watch/presence state won't be mixed.
| func saveCurrentUserUnreadChannelCountsByGroup(_ unreadChannelCountsByGroup: [String: Int]) throws { | ||
| invalidateCurrentUserCache() | ||
|
|
||
| guard let dto = currentUser else { | ||
| throw ClientError.CurrentUserDoesNotExist() | ||
| } | ||
|
|
||
| dto.unreadChannelCountsByGroup = unreadChannelCountsByGroup | ||
| } |
There was a problem hiding this comment.
Treat missing CurrentUserDTO as a cache miss here.
queryGroupedChannels and event persistence call this after a successful network response. If the client has not materialized CurrentUserDTO yet, throwing CurrentUserDoesNotExist aborts the whole write even though only the grouped unread-count cache is missing. This should be a guarded no-op instead of a hard failure.
Possible localized fix
func saveCurrentUserUnreadChannelCountsByGroup(_ unreadChannelCountsByGroup: [String: Int]) throws {
invalidateCurrentUserCache()
- guard let dto = currentUser else {
- throw ClientError.CurrentUserDoesNotExist()
- }
+ guard let dto = currentUser else { return }
dto.unreadChannelCountsByGroup = unreadChannelCountsByGroup
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| func saveCurrentUserUnreadChannelCountsByGroup(_ unreadChannelCountsByGroup: [String: Int]) throws { | |
| invalidateCurrentUserCache() | |
| guard let dto = currentUser else { | |
| throw ClientError.CurrentUserDoesNotExist() | |
| } | |
| dto.unreadChannelCountsByGroup = unreadChannelCountsByGroup | |
| } | |
| func saveCurrentUserUnreadChannelCountsByGroup(_ unreadChannelCountsByGroup: [String: Int]) throws { | |
| invalidateCurrentUserCache() | |
| guard let dto = currentUser else { return } | |
| dto.unreadChannelCountsByGroup = unreadChannelCountsByGroup | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift` around lines 149 -
157, In saveCurrentUserUnreadChannelCountsByGroup, treat a missing
CurrentUserDTO as a cache miss rather than a hard failure: after calling
invalidateCurrentUserCache(), replace the guard that throws
ClientError.CurrentUserDoesNotExist() with a safe optional check so that if
currentUser is nil the method simply returns (no-op) instead of throwing; when
dto exists assign dto.unreadChannelCountsByGroup as before. This avoids aborting
writes from callers like queryGroupedChannels and event persistence when the
CurrentUserDTO hasn't been materialized yet.
| private func linkingAction(for channel: ChatChannel) -> LinkingAction { | ||
| if let groupKey = query.groupKey { | ||
| // Group-based queries have no filter predicate; membership is decided entirely from | ||
| // the channel's "group" extra-data value. The "all" group is a catch-all that links | ||
| // every channel carrying any non-empty group value. | ||
| let currentGroupKey = channel.extraData[GroupedChannelKey.extraData]?.stringValue? | ||
| .trimmingCharacters(in: .whitespacesAndNewlines) | ||
| .lowercased() | ||
| if let currentGroupKey, !currentGroupKey.isEmpty { | ||
| return groupKey == currentGroupKey || groupKey == GroupedChannelKey.all ? .link : .unlink | ||
| } | ||
| return .none |
There was a problem hiding this comment.
Unlink grouped channels when the group value is cleared.
For grouped queries, missing or empty "group" currently resolves to .none. After a ChannelUpdatedEvent, that leaves previously linked channels stuck in grouped lists because handle never reaches unlinkChannel.
Suggested fix
if let groupKey = query.groupKey {
// Group-based queries have no filter predicate; membership is decided entirely from
// the channel's "group" extra-data value. The "all" group is a catch-all that links
// every channel carrying any non-empty group value.
let currentGroupKey = channel.extraData[GroupedChannelKey.extraData]?.stringValue?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if let currentGroupKey, !currentGroupKey.isEmpty {
return groupKey == currentGroupKey || groupKey == GroupedChannelKey.all ? .link : .unlink
}
- return .none
+ return .unlink
} else {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/StreamChat/Workers/ChannelListLinker.swift` around lines 179 - 190,
The grouping logic in linkingAction(for channel: ChatChannel) treats a
missing/empty channel.extraData[GroupedChannelKey.extraData] as .none, which
prevents unlinking previously linked channels after a ChannelUpdatedEvent;
change that final fallback to return .unlink so empty or missing group values
will remove the channel from the grouped query. Keep the existing comparisons
against query.groupKey and GroupedChannelKey.all; only replace the "return
.none" branch with "return .unlink" in the linkingAction(for:) implementation.
| database.write(converting: { session in | ||
| if isInitialFetch { | ||
| let unreadChannelCountsByGroup = payload.groups.mapValues(\.unreadChannels) | ||
| try session.saveCurrentUserUnreadChannelCountsByGroup(unreadChannelCountsByGroup) | ||
| } | ||
| var groups: [ChannelGroup] = [] | ||
| for (groupKey, groupPayload) in payload.groups { | ||
| let queryDTO = session.saveQuery(query: ChannelListQuery(groupKey: groupKey)) | ||
| if isInitialFetch || isFirstPageForSingleGroup { | ||
| queryDTO.channels.removeAll() | ||
| } | ||
| queryDTO.next = groupPayload.next | ||
| queryDTO.watch = watch | ||
| queryDTO.presence = presence | ||
| let channelIds = groupPayload.channels.compactMapLoggingError { channelPayload in | ||
| let dto = try session.saveChannel(payload: channelPayload) | ||
| queryDTO.channels.insert(dto) | ||
| return channelPayload.channel.cid | ||
| } | ||
| groups.append(ChannelGroup( | ||
| groupKey: groupKey, | ||
| channelIds: channelIds, | ||
| unreadChannels: groupPayload.unreadChannels, | ||
| next: groupPayload.next | ||
| )) | ||
| } | ||
| return groups |
There was a problem hiding this comment.
Full grouped refresh needs to clean up groups missing from the response.
When request.groups == nil, this is the authoritative snapshot of configured groups, but the write only updates groups that are still present in payload.groups. Any previously persisted group that has been removed server-side keeps its old ChannelListQueryDTO, linked channels, and next cursor, so makeChannelList(with:) can keep surfacing stale data after a config change. Please delete or clear grouped-query DTOs that are absent from a full refresh.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/StreamChat/Workers/ChannelListUpdater.swift` around lines 227 - 253,
During a full grouped refresh (when request.groups == nil / isInitialFetch true)
the current write only updates groups present in payload.groups and leaves
persisted ChannelListQueryDTOs for groups removed server-side; modify the
database.write(converting:) block in ChannelListUpdater.swift to detect a full
grouped refresh and remove or clear any saved grouped queries not present in
payload.groups: after building/updating queryDTO for each payload.groups entry,
enumerate existing saved group queries via the session (e.g., saved
ChannelListQuery/ChannelListQueryDTO entries), compute the set difference with
payload.groups.keys, and for each missing group either delete the query DTO or
clear its channels, next cursor, watch/presence flags and unread counts so
makeChannelList(with:) no longer surfaces stale data. Ensure this logic runs
only for full refreshes (isInitialFetch / request.groups == nil) and reference
queryDTO, saveQuery(query: ChannelListQuery), and makeChannelList(with:) when
locating where to apply the cleanup.
martinmitrevski
left a comment
There was a problem hiding this comment.
lgtm, let's test this extensively
| extension Int { | ||
| /// Sentinel signalling "no client-side page size" — the request omits `limit` and the backend uses its default. | ||
| /// `0` matches `NSFetchRequest.fetchLimit`'s "no limit" semantics, so it safely passes through to Core Data unchanged. | ||
| static let unsetPageSize = 0 |
There was a problem hiding this comment.
did you check also with the backend that 0 will return their default?
There was a problem hiding this comment.
It is more future proof to use -1 here since some APIs can interpret it as return nothing (although the pagination encoding below was filtering it out).
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (2)
Sources/StreamChat/Workers/ChannelListUpdater.swift (1)
231-252:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftFull grouped refresh should clear persisted groups missing from the response.
When a fetch-all response is authoritative, retaining previously saved group query DTOs that are absent in the payload can surface stale channels/cursors.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/StreamChat/Workers/ChannelListUpdater.swift` around lines 231 - 252, The grouped refresh loop currently only updates/creates queries found in payload.groups but doesn't remove previously persisted group queries that are missing when the fetch is a fresh/full refresh; update the ChannelListUpdater logic to, when wasFreshFetch (i.e., request.groups?[groupKey]?.next == nil) for the fetch, compute the set of existing saved group queries (use session.saveQuery(query: ChannelListQuery(...)) or a session query-listing method) and delete any query DTOs whose groupKey is not present in payload.groups; ensure you call the session's delete/remove method for those query DTOs (the same persistence/session API used to saveChannel/saveQuery) so stale group queries/channels/cursors are cleared.Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift (1)
149-154:⚠️ Potential issue | 🟠 Major | ⚡ Quick winTreat missing current user as a cache miss instead of a write failure.
At Line 152, throwing when
currentUseris missing makes grouped unread-count persistence fail even though this data is cache-like. A guarded no-op is safer here.Suggested fix
func mergeCurrentUserUnreadChannelCountsByGroup(_ unreadChannelCountsByGroup: [String: Int]) throws { invalidateCurrentUserCache() - guard let dto = currentUser else { - throw ClientError.CurrentUserDoesNotExist() - } + guard let dto = currentUser else { return } dto.unreadChannelCountsByGroup = (dto.unreadChannelCountsByGroup ?? [:]).merging(unreadChannelCountsByGroup) { _, new in new } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift` around lines 149 - 154, The mergeCurrentUserUnreadChannelCountsByGroup method currently treats a missing currentUser as an error and throws ClientError.CurrentUserDoesNotExist(); instead, change this to a no-op cache miss: after calling invalidateCurrentUserCache(), if currentUser is nil simply return (do not throw) so the function exits quietly when currentUser is absent; update the guard in mergeCurrentUserUnreadChannelCountsByGroup that references currentUser to an early return rather than throwing.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Sources/StreamChat/Repositories/SyncOperations.swift`:
- Around line 145-151: The code currently derives watch/presence from a single
group's pagination state (via paginationState(for:)) which is nondeterministic
when groups differ; instead, fetch paginationState(for:) for all groupKeys and
compute deterministic flags (e.g., watch = any(state.watch == true), presence =
any(state.presence == true)) and then pass those aggregated booleans into
channelListUpdater.queryGroupedChannels(groups:..., watch:..., presence:...);
update the logic around groupKeys, paginationState(for:), and the
queryGroupedChannels call to use these OR-aggregated flags rather than a single
group's values.
---
Duplicate comments:
In `@Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift`:
- Around line 149-154: The mergeCurrentUserUnreadChannelCountsByGroup method
currently treats a missing currentUser as an error and throws
ClientError.CurrentUserDoesNotExist(); instead, change this to a no-op cache
miss: after calling invalidateCurrentUserCache(), if currentUser is nil simply
return (do not throw) so the function exits quietly when currentUser is absent;
update the guard in mergeCurrentUserUnreadChannelCountsByGroup that references
currentUser to an early return rather than throwing.
In `@Sources/StreamChat/Workers/ChannelListUpdater.swift`:
- Around line 231-252: The grouped refresh loop currently only updates/creates
queries found in payload.groups but doesn't remove previously persisted group
queries that are missing when the fetch is a fresh/full refresh; update the
ChannelListUpdater logic to, when wasFreshFetch (i.e.,
request.groups?[groupKey]?.next == nil) for the fetch, compute the set of
existing saved group queries (use session.saveQuery(query:
ChannelListQuery(...)) or a session query-listing method) and delete any query
DTOs whose groupKey is not present in payload.groups; ensure you call the
session's delete/remove method for those query DTOs (the same
persistence/session API used to saveChannel/saveQuery) so stale group
queries/channels/cursors are cleared.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 80af788b-78ce-4ba8-beac-22fac01e6f4e
📒 Files selected for processing (15)
Sources/StreamChat/ChatClient.swiftSources/StreamChat/Database/DTOs/ChannelListQueryDTO.swiftSources/StreamChat/Database/DTOs/CurrentUserDTO.swiftSources/StreamChat/Database/DatabaseSession.swiftSources/StreamChat/Models/ChannelGroup.swiftSources/StreamChat/Repositories/SyncOperations.swiftSources/StreamChat/StateLayer/ChannelList.swiftSources/StreamChat/StateLayer/ChatClient+Factory.swiftSources/StreamChat/Workers/ChannelListUpdater.swiftTestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swiftTests/StreamChatTests/ChatClient_Tests.swiftTests/StreamChatTests/Database/DTOs/CurrentUserDTO_Tests.swiftTests/StreamChatTests/Repositories/SyncRepository_Tests.swiftTests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swiftTests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift
| 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 |
There was a problem hiding this comment.
Avoid deriving watch/presence from an arbitrary group state.
At Line 145 and Line 150, using groupKeys.first makes behavior non-deterministic if grouped queries have divergent persisted flags. Derive flags deterministically from all group states (e.g., OR semantics) before issuing the shared refresh.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/StreamChat/Repositories/SyncOperations.swift` around lines 145 - 151,
The code currently derives watch/presence from a single group's pagination state
(via paginationState(for:)) which is nondeterministic when groups differ;
instead, fetch paginationState(for:) for all groupKeys and compute deterministic
flags (e.g., watch = any(state.watch == true), presence = any(state.presence ==
true)) and then pass those aggregated booleans into
channelListUpdater.queryGroupedChannels(groups:..., watch:..., presence:...);
update the logic around groupKeys, paginationState(for:), and the
queryGroupedChannels call to use these OR-aggregated flags rather than a single
group's values.
There was a problem hiding this comment.
♻️ Duplicate comments (1)
Sources/StreamChat/Repositories/SyncOperations.swift (1)
141-156:⚠️ Potential issue | 🟠 Major | ⚡ Quick winAggregate
watch/presenceacross all active grouped queries, not a single sampled group.Using only
sampleGroupKeycan still produce incorrect flags when persisted grouped queries diverge.sorted().firstis deterministic, but it may disable watch/presence needed by other active groups.💡 Suggested fix
- // 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 { + guard let channelListUpdater, !groupKeys.isEmpty else { done(.continue) return } Task { do { - let state = try await channelListUpdater.paginationState(for: sampleGroupKey) + var watch = false + var presence = false + for groupKey in groupKeys { + let state = try await channelListUpdater.paginationState(for: groupKey) + watch = watch || (state.watch ?? false) + presence = presence || (state.presence ?? false) + } 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 + watch: watch, + presence: presence )🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/StreamChat/Repositories/SyncOperations.swift` around lines 141 - 156, The code samples paginationState from a single sampleGroupKey which can miss watch/presence enabled in other groups; update the logic in the block that uses channelListUpdater, groupKeys, sampleGroupKey, paginationState and queryGroupedChannels to fetch paginationState for all groupKeys (e.g. map groupKeys -> await channelListUpdater.paginationState(for: key)), then compute aggregated flags like watch = states.contains { $0.watch == true } and presence = states.contains { $0.presence == true } and pass those aggregated booleans into channelListUpdater.queryGroupedChannels instead of using state.watch/state.presence from only sampleGroupKey.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Duplicate comments:
In `@Sources/StreamChat/Repositories/SyncOperations.swift`:
- Around line 141-156: The code samples paginationState from a single
sampleGroupKey which can miss watch/presence enabled in other groups; update the
logic in the block that uses channelListUpdater, groupKeys, sampleGroupKey,
paginationState and queryGroupedChannels to fetch paginationState for all
groupKeys (e.g. map groupKeys -> await channelListUpdater.paginationState(for:
key)), then compute aggregated flags like watch = states.contains { $0.watch ==
true } and presence = states.contains { $0.presence == true } and pass those
aggregated booleans into channelListUpdater.queryGroupedChannels instead of
using state.watch/state.presence from only sampleGroupKey.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: cd391aea-7ea0-4744-a72a-e79d8656c151
📒 Files selected for processing (9)
CHANGELOG.mdSources/StreamChat/Database/DTOs/CurrentUserDTO.swiftSources/StreamChat/Database/DatabaseSession.swiftSources/StreamChat/Repositories/SyncOperations.swiftTests/StreamChatTests/ChatClient_Tests.swiftTests/StreamChatTests/Database/DTOs/CurrentUserDTO_Tests.swiftTests/StreamChatTests/Database/DatabaseSession_Tests.swiftTests/StreamChatTests/Repositories/SyncRepository_Tests.swiftTests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift
✅ Files skipped from review due to trivial changes (1)
- CHANGELOG.md
Public Interface+ public protocol HasUnreadChannelCountsByGroup: Event
+ public struct ChannelGroup: Sendable
+
+ public let groupKey: String
+ public let unreadChannels: Int
+ public let channelIds: [ChannelId]
- public final class NotificationMessageNewEvent: ChannelSpecificEvent, HasUnreadCount
+ public final class NotificationMessageNewEvent: ChannelSpecificEvent, HasUnreadCount, HasUnreadChannelCountsByGroup
+ public let unreadChannelCountsByGroup: [String: Int]?
- public final class MessageNewEvent: ChannelSpecificEvent, HasUnreadCount
+ public final class MessageNewEvent: ChannelSpecificEvent, HasUnreadCount, HasUnreadChannelCountsByGroup
+ public let unreadChannelCountsByGroup: [String: Int]?
public class ChatClient: @unchecked Sendable
- public func uploadAttachment(localUrl: URL,progress: (@Sendable (Double) -> Void)?,completion: @escaping @Sendable (Result<UploadedFile, Error>) -> Void)
+ @discardableResult public func queryGroupedChannels(groups: [String],limit: Int? = nil,presence: Bool = false,watch: Bool = false)async throws -> [ChannelGroup]
- public func deleteAttachment(remoteUrl: URL,completion: @escaping @Sendable (Error?) -> Void)
+ public func uploadAttachment(localUrl: URL,progress: (@Sendable (Double) -> Void)?,completion: @escaping @Sendable (Result<UploadedFile, Error>) -> Void)
+ public func deleteAttachment(remoteUrl: URL,completion: @escaping @Sendable (Error?) -> Void)
- public final class NotificationMarkUnreadEvent: ChannelSpecificEvent
+ public final class NotificationMarkUnreadEvent: ChannelSpecificEvent, HasUnreadChannelCountsByGroup
- public let unreadMessagesCount: Int
+ public let unreadChannelCountsByGroup: [String: Int]?
+ public let unreadMessagesCount: Int
- public final class ChannelTruncatedEvent: ChannelSpecificEvent
+ public final class ChannelTruncatedEvent: ChannelSpecificEvent, HasUnreadChannelCountsByGroup
+ public let unreadChannelCountsByGroup: [String: Int]?
- public final class NotificationMarkReadEvent: ChannelSpecificEvent, HasUnreadCount
+ public final class NotificationMarkReadEvent: ChannelSpecificEvent, HasUnreadCount, HasUnreadChannelCountsByGroup
- public let lastReadMessageId: MessageId?
+ public let unreadChannelCountsByGroup: [String: Int]?
- public let createdAt: Date
+ public let lastReadMessageId: MessageId?
+ public let createdAt: Date
- public final class NotificationChannelDeletedEvent: ChannelSpecificEvent
+ public final class NotificationChannelDeletedEvent: ChannelSpecificEvent, HasUnreadChannelCountsByGroup
+ public let unreadChannelCountsByGroup: [String: Int]?
public class CurrentChatUser: ChatUser, @unchecked Sendable
- public let isInvisible: Bool
+ public let unreadChannelCountsByGroup: [String: Int]?
- public let privacySettings: UserPrivacySettings
+ public let isInvisible: Bool
- public let pushPreference: PushPreference?
+ public let privacySettings: UserPrivacySettings
+ public let pushPreference: PushPreference?
@MainActor public final class ChannelListState: ObservableObject
- public let query: ChannelListQuery
+ public private var query: ChannelListQuery |
SDK Size
|
StreamChat XCSize
Show 42 more objects
|
|
watch: true is required to receive channel.updated events for the returned channels.
| if let lastMessageAt = payload.lastMessageAt { | ||
| dto.lastMessageAt = lastMessageAt.bridgeDate | ||
| } |
There was a problem hiding this comment.
@laevandus This can be dangerous, AFAIK, in some scenarios, we do want lastMessageAt to be reset to nil. This most likely will break some scenarios. Why was this unwrapping added?
There was a problem hiding this comment.
This is from the initial batch of changes when Martin started with it. I think I should remove it because the initial implementation had logic around it.
| request.fetchLimit = query.pagination.pageSize == .unsetPageSize ? 0 : query.pagination.pageSize | ||
| // For grouped queries `pageSize` is `.unsetPageSize`, which would disable batching. | ||
| // Fall back to the standard channels page size to keep memory bounded as the linked set | ||
| // grows across pagination. | ||
| request.fetchBatchSize = query.pagination.pageSize == .unsetPageSize | ||
| ? .channelsPageSize | ||
| : query.pagination.pageSize |
There was a problem hiding this comment.
Why is this needed? Not very clear from the docs 🤔
| /// `ChatChannel.extraData` field that carries the channel's group membership. | ||
| static let extraData = "group" |
There was a problem hiding this comment.
This is a bit weird, shouldn't it be static let group = "group"?
| /// The stable identity used for locating / linking the corresponding `ChannelListQueryDTO`. | ||
| /// Uses `groupKey` when set (stable across date-bearing filters from the grouped endpoint); | ||
| /// otherwise falls back to `filter.filterHash`. |
There was a problem hiding this comment.
What are date-bearing filters? Maybe we can select a bit easier wording here
There was a problem hiding this comment.
I should remove it, this is part of the second iteration of this feature and it is not relevant with what we have now. At some point there were queries with Date() in it.
| /// Fetches the first page of channels from the server and registers the list for reconnect sync. | ||
| /// | ||
| /// - For filter-based lists, ``ChannelListState/channels`` is reset to the first page returned | ||
| /// by the channels endpoint. | ||
| /// - For group-based lists (created via ``ChatClient/makeChannelList(with:)-(String)``), the | ||
| /// first page is fetched from the grouped endpoint with no cursor; the request inherits the | ||
| /// `watch` / `presence` flags persisted by the most recent | ||
| /// ``ChatClient/queryGroupedChannels(groups:limit:presence:watch:)`` call for the group. | ||
| /// | ||
| /// Subsequent pages are loaded via ``loadMoreChannels(limit:)``. | ||
| /// | ||
| /// - Important: Loaded channels in ``ChannelListState/channels`` are reset. | ||
| /// - Important: For group-based lists, prefer `get()` only when fetching the first page for a *single* group | ||
| /// in isolation. When the app needs first pages for multiple groups, call | ||
| /// ``ChatClient/queryGroupedChannels(groups:limit:presence:watch:)`` instead — it returns every group | ||
| /// in one request, which is significantly more efficient than calling `get()` per `ChannelList`. |
There was a problem hiding this comment.
I'm a bit concerned we are adding a lot of docs to one of the most used functions for something that will be disabled for 99% of customers. This will just add too much noise. Maybe we could introduce a separate function called getGrouped() or something like this
| /// - Important: For filter-based lists, loaded channels are reset when the pagination offset is 0 and | ||
| /// the cursor is nil. For group-based lists, only ``Pagination/cursor`` is used — the offset is ignored — | ||
| /// and the grouped endpoint controls the page contents. |
There was a problem hiding this comment.
Same here. Feels like we should have seperate functions for this special niche feature.
| /// 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 fetches with queryGroupedChannels | ||
| syncRepository.startTrackingChannelList(channelList) | ||
| return channelList | ||
| } |
There was a problem hiding this comment.
Just like we have a seperate function for grouped channels, it probably makes sense to have seperate functions to interact with it. Or even return a ChannelGroupedList object.
| } | ||
|
|
||
| // MARK: - |
There was a problem hiding this comment.
Can we remove this empty mark?
Revert ChannelList get/loadChannels/loadMoreChannels docs to their original wording and condense the queryGroupedChannels and ChannelGroup documentation so the grouped channels API is less prominent.



🔗 Issue Links
Resolves https://linear.app/stream/issue/IOS-1635/support-for-grouped-channels-endpoint.
🎯 Goal
ChatClient.queryGroupedChannels(limit:presence:watch:)(callback +async throwsoverloads) that fetches grouped channel groups fromPOST /channels/groupedand returns them as a publicChannelGroupmodel (group key, channel ids, per-group unread channel count).ChatClient.makeChannelList(with: groupKey)— state-layer factory that creates aChannelListfor a single group, with pagination, observation, and offline-sync wired in.CurrentChatUser.groupedUnreadCount: [String: Int]?— per-group unread-channel counts, decoded fromgrouped_unread_channelson relevant WS events (NotificationMessageNew,NotificationMarkRead,NotificationMarkUnread,MessageNew,ChannelTruncated,NotificationChannelDeleted) via the newHasGroupedUnreadCountprotocol and persisted onCurrentUserDTO.📝 Summary
Test on https://github.com/GetStream/GroupedChannelsSample.
This is the v5 port of the
grouped-channels-endpointwork that originally landed on the v4 line. All public-API changes are additive; the legacyChatChannelListControlleris intentionally not extended for grouped channels — group-based observation lives only in the state-layerChannelList.🛠 Implementation
POST /channels/groupedwired throughChannelEndpoints/EndpointPathwithGroupedQueryChannelsRequestBody/GroupedQueryChannelsPayload/GroupedQueryChannelsGroupPayload.ChannelListbranches onquery.groupKey— initial fetch and pagination go throughqueryGroupedChannels(the regular/channelspath is skipped). Pagination cursors are persisted onChannelListQueryDTO.next.SyncGroupedChannelsOperationruns once during the offline-sync chain and issues a single sharedqueryGroupedChannelsfor all active group-basedChannelLists, populating their query DTOs so each list's subsequent reads hit the cache.ChannelListLinkerdetects a channel's group viachannel.extraData["group"](GroupedChannelKey.extraData) and routes link/unlink decisions accordingly. The reserved"all"group key (GroupedChannelKey.all) is a union — every channel that has a non-empty group is linked to it.ChannelListUpdater.linkincrements andunlinkdecrementsCurrentUserDTO.groupedUnreadCount[groupKey](floored at 0) when the channel actually has unread messages and the membership set actually changes. The"all"group is skipped — its count is driven by server-side WS events.limit > 10. Instead of hard-coding10,ChannelListQuery(groupKey:)usesPagination(pageSize: .unsetPageSize)(=-1), andPagination.encodeskips writinglimitwhen negative — letting the backend pick its default.ChannelListQueryDTO.next: String?— per-group pagination cursor.CurrentUserDTO.unreadGroupedChannelsCounts: Data?— JSON-encoded[String: Int], exposed via the computedgroupedUnreadCountaccessor.develop:Sendableconformance on the new payloads/models,@Sendableon completion handlers,nonisolated(unsafe)for mutated locals captured indatabase.writeblocks, and explicit@Sendableon the inner closures insideSyncGroupedChannelsOperationto escape@MainActorinheritance from thequeryGroupedChannelscallback (otherwise crashes at_swift_task_checkIsolatedSwifton.utility-qos).🎨 Showcase
Add relevant screenshots and/or videos/gifs to easily see what this PR changes, if applicable.
🧪 Manual Testing Notes
Use the GroupedChannelsSample app. Verify:
queryGroupedChannelsreturns groups with correct per-group unread channel counts.ChannelListfor agroupKeyshows that group's channels and paginates through them.groupedUnreadCountonCurrentChatUservia WS events."all"group adjusts the localgroupedUnreadCountimmediately; the"all"group reflects the server's value.☑️ Contributor Checklist
docs-contentrepoSummary by CodeRabbit
New Features
Tests